From e65cc6c3d0bb78b691825dc550f0d2f3577f3cb1 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 12 Sep 2026 01:39:51 -0500 Subject: [PATCH 01/29] Add hierarchy generations and ancestor workspace leases (#233) * feat(orchestrator): add hierarchy generations and durable leases Summary: - Persist scheduler-owned child-edge generations and owner-identified lease records. - Gate parent dispatch on provider evidence and retained checkout resources. - Reconcile restart cleanup and terminal conversation retention through atomic state. Rationale: - Parent scope must be versioned and frozen before integration preparation. - Lease ownership must survive restart and prevent cleanup from deleting evidence. Tests: - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb --test workspace_manager - cargo test-system-duckdb --lib opensymphony_workspace::manager::tests - cargo clippy-system-duckdb - git diff --check * fix(orchestrator): close hierarchy lease review gaps Summary: - Add provider-backed merge evidence and durable dispatch intents. - Fence lease recovery, expiry, cleanup, and hierarchy reconciliation. Rationale: - Prevent parent dispatch from depending on tracker terminal state alone. - Preserve retained child evidence across launch failures and restarts. Tests: - cargo test-system-duckdb hierarchy:: --lib - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb --test workspace_manager - cargo test-system-duckdb manager:: --lib - cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib - cargo clippy-system-duckdb - git diff --check * fix(orchestrator): harden hierarchy recovery boundaries Summary: - Delay parent dispatch intents until admission checks pass. - Restore terminal recovery evidence and generation-fresh leaf leases. - Preserve frozen parent identity and tolerate fresh workspace roots. Rationale: - Prevent transient capacity, persistence, provider, and restart conditions from permanently parking eligible work or allowing unsafe parent integration. Tests: - cargo check-system-duckdb --all-targets - cargo test-system-duckdb hierarchy:: --lib - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb --test workspace_manager - cargo test-system-duckdb manager:: --lib - cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib - cargo clippy-system-duckdb - git diff --check * fix(orchestrator): preserve hierarchy provenance Summary: - Persist run hierarchy generations and refresh stale child leases safely. - Bind merge evidence to canonical repositories with bounded requests. - Retry hierarchy persistence and reconcile hydrated parent edges. Rationale: - Keep late child outcomes fenced to their originating hierarchy generation. - Prevent provider stalls, stale snapshots, and repeated lookups from blocking the scheduler or admitting unrelated merge evidence. Tests: - cargo check-system-duckdb --all-targets - cargo test-system-duckdb hierarchy:: --lib - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb --test workspace_manager - cargo test-system-duckdb manager:: --lib - cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib - cargo clippy-system-duckdb - git diff --check * fix(orchestrator): fence parent launch preparation * fix(orchestrator): close nested hierarchy lease gaps * fix(orchestrator): bind merge evidence to current child run * fix(orchestrator): close nested lease recovery gaps * fix(orchestrator): close lease recovery gaps Summary: - preserve nested lease evidence until a parent launch succeeds - enforce configured review and check policy for merge evidence - retain recovery records and terminal executions across lease write failures Rationale: - prevent repeated terminal reconciliation from resurrecting released leaf leases - keep hierarchy snapshots and provider evidence aligned across restart and launch failure Tests: - cargo check-system-duckdb --all-targets - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb hierarchy:: --lib - cargo test-system-duckdb --test workspace_manager - cargo test-system-duckdb manager:: --lib - cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(lifecycle): close provider and recovery edge cases Summary: - use review-profile credentials and complete paginated check evidence - preserve hierarchy generations and hydrated execution details across refreshes - fail closed on workspace-root drift and restore recovery batches on lookup errors Rationale: - keep parent eligibility tied to the configured provider policy and current durable checkout evidence - prevent restart, generation, and repeated-reconciliation paths from losing retained workspaces Tests: - cargo check-system-duckdb --all-targets - cargo test-system-duckdb hierarchy:: --lib - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb --test workspace_manager - cargo test-system-duckdb manager:: --lib - cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(orchestrator): close hierarchy recovery gaps Fence active parent runs when frozen hierarchy scope changes, reconcile terminal parent snapshots, reset stale dispatch claims after reactivation, and hand off nested ancestor leases during recovery. Paginate GitHub review evidence so provider eligibility remains complete for large pull requests. Tests: cargo check-system-duckdb --all-targets Tests: cargo clippy-system-duckdb Tests: cargo test-system-duckdb hierarchy:: --lib Tests: cargo test-system-duckdb --test scheduler --test hierarchy_selection Tests: cargo test-system-duckdb --test workspace_manager Tests: cargo test-system-duckdb manager:: --lib Tests: cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib Tests: cargo test-system-duckdb github_provider_merge_evidence_ignores_stale_reviews_and_requires_configured_checks --lib Tests: cargo fmt --all -- --check Tests: git diff --check * fix(hierarchy): fence stale parent evidence Restrict dispatch claim resets to active reactivations, reconcile and fence hierarchy changes before worker completions without changing recovery order, preserve repository identities across descendant merge evidence, and model configured canceled children as non-required edges. Tests: cargo check-system-duckdb --all-targets Tests: cargo clippy-system-duckdb Tests: cargo test-system-duckdb hierarchy:: --lib Tests: cargo test-system-duckdb --test scheduler --test hierarchy_selection Tests: cargo test-system-duckdb --test workspace_manager Tests: cargo test-system-duckdb manager:: --lib Tests: cargo test-system-duckdb github_provider_merge_evidence_ignores_stale_reviews_and_requires_configured_checks --lib Tests: cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib Tests: cargo fmt --all -- --check Tests: git diff --check * fix(lifecycle): preserve descendant lease provenance Carry retained in-flight children onto the current hierarchy generation, release nested evidence when an undispatched root finalizes, and reject merge evidence when its configured review provider is not supported by the backend. Add a regression for clearing nested evidence during root finalization. Tests: cargo test-system-duckdb hierarchy:: --lib Tests: cargo test-system-duckdb --test scheduler --test hierarchy_selection Tests: cargo test-system-duckdb --test workspace_manager Tests: cargo test-system-duckdb manager:: --lib Tests: cargo test-system-duckdb github_provider_merge_evidence_ignores_stale_reviews_and_requires_configured_checks --lib Tests: cargo test-system-duckdb retained_terminal_cleanup_keeps_openhands_conversation_active --lib Tests: cargo check-system-duckdb --all-targets Tests: cargo clippy-system-duckdb Tests: cargo fmt --all -- --check Tests: git diff --check * fix(hierarchy): close lease recovery review gaps * fix(eligibility): close provider evidence gaps * fix(orchestrator): close hierarchy review gaps * fix(orchestrator): close nested eligibility review gaps * fix(orchestrator): close final review findings * fix(eligibility): close final provider and lease gaps * fix(hierarchy): close ancestor lease review gaps * fix(hierarchy): close provider and ancestry review gaps Summary: - require every protected check context across Checks and commit statuses - encode protected branch path segments and classify tracker canceled kinds - fence nested descendants and reject unresolved terminal leaf routing Rationale: - parent eligibility must fail closed when provider evidence is incomplete - active ancestor leases must protect every retained descendant generation Tests: - cargo test-system-duckdb --test hierarchy_selection -- --nocapture - cargo test-system-duckdb --test scheduler -- --nocapture - cargo test-system-duckdb --lib hierarchy::tests -- --nocapture - cargo test-system-duckdb --lib opensymphony_cli::orchestrator_run::backends::tests -- --nocapture - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(hierarchy): close replan and check identity gaps Summary: - Scope parent replans to fresh operation keys while allowing retries to reuse a key. - Preserve GitHub required-check app identities and match successful check runs to them. - Add transport, gateway, and provider regressions for both review findings. Rationale: - A permanent parent-only key prevents later hierarchy generations from being replanned after the first accepted request. - Collapsing protected checks to names permits evidence from the wrong GitHub App or from an unbound classic status. Tests: - cargo test-system-duckdb --lib opensymphony_cli::orchestrator_run::backends::tests - npm test - npm run type-check - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(hierarchy): close provider recovery gaps Summary: - Accept GitHub's any-App required-check sentinel while preserving App identity matching. - Scale descendant eligibility deadlines, preserve retryable replans, and retain unschedulable-parent evidence. - Validate GitHub review credentials and add scheduler/config regressions. Rationale: - Prevent provider evidence and action-journal failures from being silently consumed. - Keep retained checkout generations available across delayed parent hydration without crossing the COE-553 handoff boundary. Tests: - cargo test-system-duckdb --all-targets (scope suites green; unrelated supervisor fixture fails to launch fake server locally) - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - npm test -- --runInBand * fix(hierarchy): fence stale generations and provider evidence Preserve the old hierarchy generation until a fenced run is stopped, avoid retaining evidence for terminal undispatched parents, and keep finished children pinned to their dispatched generation. Bound pull-request evidence lookups, reject missing explicit review tokens, and verify recorded merge commits remain reachable from the target branch. Tests: cargo fmt --all; git diff --check; cargo check-system-duckdb --all-targets; cargo clippy-system-duckdb; cargo test-system-duckdb --test scheduler; cargo test-system-duckdb --test hierarchy_selection; focused GitHub evidence regression * fix(hierarchy): fence stale parent and run evidence Summary: - verify the current tracker parent state before suppressing terminal-child cleanup - carry provider evidence timestamps and reject stale evidence from prior child runs - reject unsupported active review providers during central validation Rationale: - terminal parent markers can outlive a parent state transition and otherwise authorize premature cleanup - reactivated children must not inherit an older merged PR on the same branch - unsupported providers must fail during configuration rather than silently block eligibility Tests: - cargo clippy-system-duckdb - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --test hierarchy_selection - cargo test-system-duckdb central_config_rejects_unsupported_active_review_provider - cargo test-system-duckdb current_merge_evidence_rejects_stale_merged_pr_when_replacement_is_newer - cargo fmt --all - git diff --check * fix(config): preserve inactive review profiles Summary: - enforce GitHub provider and environment credentials only for active review/check policies - preserve legacy inactive profiles used by migration and central-memory flows - keep generation coverage for an inactive non-GitHub policy Rationale: - unsupported providers must fail before parent eligibility can silently treat their evidence as unavailable - inactive profiles do not participate in review evidence and must remain backward-compatible Tests: - cargo test-system-duckdb - cargo test-system-duckdb central_config_tracks_scheduler_and_review_policy_generations_separately - cargo test-system-duckdb central_config_rejects_unsupported_active_review_provider - cargo test-system-duckdb central_memory_load_uses_typed_checkout_for_repository_policy - cargo test-system-duckdb apply_and_rollback_restore_legacy_files - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): fence stale provider evidence paths Summary: - validate every active review profile against the supported GitHub provider - bind merge evidence to merge time and newest required commit statuses - reconcile reactivated parents, nested generations, and replacement leaf leases Rationale: - optional review flags do not remove the provider evidence required for parent eligibility - mutable PR metadata and historical status entries must not satisfy a newer child run - reactivated or rebound hierarchy resources must not dispatch from stale snapshots or leases Tests: - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb required_status_context_uses_the_newest_commit_status - cargo test-system-duckdb central_config_tracks_scheduler_and_review_policy_generations_separately - cargo test-system-duckdb central_config_rejects_unsupported_active_review_provider - cargo fmt --all - git diff --check * fix(config): preserve legacy non-GitHub profiles Summary: - limit unsupported review-provider rejection to GitHub-backed merge evidence or explicitly required review/check policies - keep legacy non-GitHub checkout profiles valid when no merge policy is active Rationale: - older migration and memory configurations use a generic git review profile without a provider-backed parent merge policy - GitHub repositories and protected review policies still fail closed for unsupported providers as required by hierarchy eligibility Tests: - cargo test-system-duckdb --lib - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): fence leases across scope replans Summary: - retain live ancestor and subtree leases across tracker-only finalization - validate active project-set merge providers and GitHub credentials - preserve in-flight execution fences and resolve conversation suffix aliases Rationale: - prevent cleanup or redispatch from racing a still-running parent - fail closed before unsupported provider evidence can strand a hierarchy - keep durable hierarchy state aligned with tracker parentage and gateway IDs Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): fence nested provider evidence Summary: - Persist provider evidence boundaries for every descendant leaf. - Reject stale nested merge evidence against each leaf run. - Revalidate provider eligibility before retrying a dispatched parent. Rationale: - A nested descendant can be reactivated after an older merge, so one aggregate timestamp cannot fence every leaf checkout generation. - Parent retries must refresh provider truth after target or review state changes while retaining the existing hierarchy generation and leases. Tests: - cargo check-system-duckdb --all-targets - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): persist recovery run boundaries Summary: - Persist run-start boundaries in run manifests and durable orchestrator state. - Hydrate descendant boundaries before recovery eligibility checks. - Reuse retained parent ancestor leases when retrying a parent dispatch. Rationale: - Provider merge evidence must be fenced after restart by the descendant's latest run boundary, even before an in-memory execution is rehydrated. - Parent retries retain checkout resources under the parent owner after the initial dispatch releases child worker leases, so eligibility must recover those durable resources. Tests: - cargo fmt --all - git diff --check - cargo check-system-duckdb --all-targets - cargo test-system-duckdb --lib hierarchy::tests:: -- --nocapture - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb --lib opensymphony_cli::orchestrator_run::backends::tests::recover_workspaces_ - cargo test-system-duckdb --lib (1269 passed) - cargo clippy-system-duckdb * fix(hierarchy): close recovery and action validation gaps Summary: hydrate recovered parent edges from active and terminal tracker detail; record explicit completion or rejection events for replan no-ops; validate review credential variables against runtime environment usage. Rationale: preserve higher-ancestor lease evidence across terminal recovery, make accepted replans observable when durable hierarchy state rejects them, and fail closed before scrubbed credentials break worker startup. Tests: cargo fmt --all; git diff --check; cargo check-system-duckdb --all-targets; cargo test-system-duckdb --lib; cargo test-system-duckdb --test scheduler --test hierarchy_selection; cargo clippy-system-duckdb. * fix(hierarchy): close replan and projection recovery gaps Summary: - Treat disappeared replan targets as rejected actions and advance recovery. - Release and rebind durable ancestor leases across parent recovery and replans. - Project hierarchy generation and blocked reasons through control-plane output. - Add regression coverage for lease retention, projection, and missing targets. Rationale: - Restart and action processing must not strand cursors or leases when tracker state changes between validation and execution. - Operators need durable hierarchy blockers and generations in the same snapshot used by task-graph and run-detail clients. Tests: - cargo fmt --all -- --check - git diff --check - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler --test hierarchy_selection - cargo test-system-duckdb --test gateway --test action_handler --test task_graph_mutations_routes * fix(hierarchy): close latest lease review gaps Summary: - Keep live parent leases through in-flight fencing and combine retained resources with new leaf leases. - Refresh hierarchy edges before replanning and ignore deleted historical PR evidence. Rationale: - Prevent terminal cleanup or parent eligibility from crossing a running generation fence. - Preserve valid provider evidence when stale historical candidates disappear. Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo clippy-system-duckdb - cargo fmt --all && git diff --check * fix(hierarchy): fence recovered parent dispatches Restore in-flight fences from durable dispatch intents before terminal reconciliation, keep recovered parent leases until worker stop acknowledgement, and reopen only cleaned dispatched parents. Scale eligibility timeouts for nested traversal rounds and cover the recovery invariants with focused tests. Tests: - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --lib - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): preserve reactivation and running evidence Allow retained intermediate parents to reactivate while a higher ancestor owns their evidence, while keeping root dispatch claims fenced by active leases. Rebind unchanged running children only when an unfrozen hierarchy generation advances so their terminal evidence matches the new scope. Tests: - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --lib - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(leases): reject stale child evidence and protection gaps Require a current hierarchy edge before retaining a terminal child lease, and preserve branch-protection 404s as unresolved evidence instead of treating them as an unprotected branch. Keep historical PR 404 filtering scoped to candidate lookups. Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): scope retained evidence and eligibility diagnostics Summary: - Scope ancestor resources to the current hierarchy subtree and hydrate tracker parents before terminal cleanup. - Release canceled-only holds, propagate non-candidate provider failures, and project transient eligibility blocks through the control-plane snapshot. Rationale: - Prevent removed or inactive child relationships from retaining or satisfying the wrong checkout evidence while preserving restart-safe cleanup. - Keep provider failures fail-closed without hiding actionable diagnostics or turning transient eligibility failures into permanent replans. Tests: - cargo test-system-duckdb --lib (1275 passed) - cargo test-system-duckdb --test scheduler (96 passed) - cargo test-system-duckdb --test hierarchy_selection (2 passed) - cargo test-system-duckdb --test linear_client (41 passed, 1 ignored) - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): prune stale retry evidence Summary: - Reuse only current-subtree ancestor resources when rebuilding direct-child retry evidence. - Prune durable run boundaries and hierarchy-generation hints once no active hierarchy, lease, workspace, or execution retains the issue. Rationale: - Prevent removed children from pairing old checkouts with a replacement child's merge evidence. - Keep restart-safe evidence bounded after terminal cleanup and lease release. Tests: - cargo test-system-duckdb --lib (1276 passed) - cargo test-system-duckdb --test scheduler (96 passed) - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): persist terminal parent outcomes Summary: - Persist successful nested-parent outcomes beyond workspace cleanup and restart recovery. - Scope retained parent resources to the direct child subtree and fence canceled-subtree release until the child is durably terminal. - Add hierarchy regression coverage for sibling resource isolation and terminal-outcome pruning. Rationale: - Higher parents need durable child completion evidence after an intermediate workspace disappears. - A parent must never pair a replacement child with a sibling checkout or release descendant holds while its child worker is still live. Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --lib ancestor_resources_for_child_ignore_sibling_resources - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(leases): close root review and compare error gaps Summary: - Release root-owned review leases during undispatched subtree cleanup. - Propagate GitHub compare endpoint failures instead of hiding them as missing history. Rationale: - A crashed undispatched parent must not orphan review holds that retain child evidence. - Authorization and provider failures must remain visible to eligibility instead of being treated as absent merge evidence. Tests: - cargo test-system-duckdb --lib undispatched_root_release_clears_nested_child_evidence - cargo test-system-duckdb --lib historical_pr_404_filter_does_not_hide_protection_404 - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --lib (1277 passed) - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): close latest parent lease review gaps Summary: - Fence nested parent eligibility before durable intent and lease acquisition. - Release evidence when an off-scope terminal parent is undispatched. - Accept neutral and skipped completed required checks. - Document paginated multi-PR and Enterprise attachment normalization. Rationale: - Active ancestor leases must remain the sole mutation fence without leaving stale nested dispatch intents behind. - Terminal parent confirmation must authorize cleanup of retained subtree evidence even when the parent is outside the configured project scan. - GitHub treats completed neutral and skipped required checks as passing. - Operators need the documented contract to match the normalized Linear data. Tests: - cargo test-system-duckdb --test scheduler (97 passed) - cargo test-system-duckdb --lib (1278 passed) - cargo clippy-system-duckdb - cargo fmt --all - git diff --check * fix(hierarchy): fence recovery and bind replans to generations Summary: - Restore recovered dispatch fences and propagate persisted run start times. - Release removed nested subtree owners and require displayed replan generations. - Update gateway, API client, web, and desktop action contracts. Rationale: - Prevent reconciliation from redispatching an in-flight parent or accepting provider evidence from before the recovered run. - Keep removed descendant evidence cleanup scoped and make stale operator actions fail closed. Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --test hierarchy_selection - cargo test-system-duckdb --test gateway - cargo clippy-system-duckdb - cargo check-system-duckdb --all-targets - cargo fmt --all -- --check - git diff --check - npm run type-check * fix(hierarchy): preserve reparented leases at ingress Summary: - Keep leaf leases reachable from another active hierarchy during subtree release. - Reject replan actions without a numeric hierarchy generation at the gateway. - Add regressions for cross-hierarchy reparenting and malformed replans. Rationale: - Releasing a moved child before global reachability cleanup loses the checkout evidence required by its new parent. - Acknowledging generation-less replans creates accepted events that the orchestrator cannot execute or report. Tests: - cargo test-system-duckdb --lib (1282 passed) - cargo test-system-duckdb --test scheduler (97 passed) - cargo test-system-duckdb --test gateway (77 passed) - cargo clippy-system-duckdb - cargo check-system-duckdb --all-targets - npm run type-check - cargo fmt --all -- --check - git diff --check * fix(hierarchy): preserve moved evidence across replan races Summary: - derive removed-child reachability from the complete tracker snapshot - retain ancestor-integration leases for reparented nested children - journal expected fenced replan failures before advancing the action cursor Rationale: - active-parent reconciliation order must not release evidence before a new parent edge is materialized - a failed operator replan must not abort the scheduler tick loop Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --test gateway - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check - npm run type-check * fix(hierarchy): retain inactive reparent and tie PR evidence Summary: - retain moved leases from tracker parent links outside active states - tie equal-time GitHub evidence by pull-request number - add inactive-parent and deterministic-selection regressions Rationale: - inactive parents are not in candidate or terminal snapshots - unordered attachment lookups need a stable replacement ordering Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --test gateway - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check - npm run type-check * fix(hierarchy): preserve reparented terminal evidence Summary: - defer undispatched-parent descendant release until tracker reachability reconciliation - roll back hierarchy changes when replan persistence fails - add regressions for reparented evidence and failed replan writes Rationale: - terminal cleanup can precede the next tracker parent-edge refresh - failed atomic writes must not advance the in-memory generation Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --test gateway - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check - npm run type-check * fix(hierarchy): recover stale binding and terminal evidence Summary: - Release leases for stale binding generations before replacement cleanup. - Retry terminal outcome receipts after durable persistence failures. - Normalize SSH default ports for GitHub authority matching. Rationale: - Prevent retained old generations from blocking replacement recovery. - Keep in-memory terminal receipts retryable when the first write fails. - Match canonical enterprise SSH and HTTPS authorities. Tests: - cargo test-system-duckdb --lib --quiet - cargo test-system-duckdb --test scheduler --quiet - cargo test-system-duckdb --test gateway --quiet - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check - npm run type-check * fix(scheduler): retain current recovery evidence Preserve reparented child evidence when terminal parent eligibility is evaluated by using the latest complete tracker edge snapshot. Retain recovered run-start issue IDs during the pre-hydration durable-state write so restart evidence is not pruned before recovery hydration. Tests: cargo test-system-duckdb --lib --quiet; cargo test-system-duckdb --test scheduler --quiet; cargo test-system-duckdb --test gateway --quiet; cargo check-system-duckdb --all-targets; cargo clippy-system-duckdb; npm run type-check; cargo fmt --all -- --check; git diff --check * fix(hierarchy): close review rework gaps Thread reachability from the current full tracker snapshot before releasing canceled-subtree evidence, and prune recovered IDs with a durable cleanup write after hydration. Keep generic legacy Git providers on their compatible leaf-completion path instead of incompatible GitHub evidence. Replay the original accepted receipt for idempotent action retries and cover terminal recovery boundary cleanup. Validation: cargo test-system-duckdb --test scheduler --quiet (100 passed); cargo test-system-duckdb --test hierarchy_selection --quiet (2 passed); cargo test-system-duckdb --test gateway --quiet (77 passed); cargo test-system-duckdb --lib --quiet (1286 passed); cargo check-system-duckdb --all-targets; cargo clippy-system-duckdb; npm run type-check; cargo fmt --all -- --check; git diff --check. * test(gateway): align idempotency replay assertions Update the workspace action-handler integration tests to match accepted receipt replay for repeated idempotency keys, including concurrent retries that produce one journal dispatch. Validation: cargo test-system-duckdb --test action_handler --quiet (16 passed); cargo test-system-duckdb --test gateway --quiet (77 passed); cargo test-system-duckdb --test scheduler --quiet (100 passed); cargo test-system-duckdb --lib --quiet (1286 passed); cargo check-system-duckdb --all-targets; cargo clippy-system-duckdb; npm run type-check; cargo fmt --all -- --check; git diff --check. * fix(hierarchy): close latest lease review gaps Summary: - Defer summary and known-candidate parent dispatch until complete reachability is available. - Keep recovery-boundary cleanup dirty after failed durable persistence and select the latest matching GitHub check run. - Canonicalize synthetic orchestrator-state handles and cover symlinked workspace roots. Rationale: - Parent cleanup must have the same complete tracker edge set as eligibility so canceled-subtree leases cannot strand retained workspaces. - Failed recovery writes need a scheduler-owned retry signal, and historical successful checks must not override newer failures. - State artifacts must follow the canonical workspace root accepted by normal workspace validation. Tests: - cargo test-system-duckdb --lib --quiet (1288 passed) - cargo test-system-duckdb --test scheduler --quiet (100 passed) - cargo test-system-duckdb --test hierarchy_selection --quiet (2 passed) - cargo test-system-duckdb --test gateway --quiet (77 passed) - cargo test-system-duckdb --test action_handler --quiet (16 passed) - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - npm run type-check - cargo fmt --all -- --check - git diff --check - opensymphony memory context --issue COE-552 --include-code-intel * fix(hierarchy): close latest review gaps * fix(hierarchy): close latest review gaps Summary: - Rebind finished child evidence to the current hierarchy generation. - Make idempotency replay identity ignore correlation IDs. - Break equal-time commit-status ties with GitHub status IDs. Rationale: - In-flight children need a durable generation handoff after stopping so active retries can produce evidence for the current parent scope. - API retries may generate a new correlation ID while reusing their key. - Provider response order must not decide the newest required status. Tests: - cargo test-system-duckdb --lib --quiet - cargo test-system-duckdb --test scheduler --quiet - cargo test-system-duckdb --test hierarchy_selection --quiet - cargo test-system-duckdb --test gateway --quiet - cargo test-system-duckdb --test action_handler --quiet - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - npm run type-check - cargo fmt --all -- --check - git diff --check * fix(hierarchy): address latest provider review findings Summary: - exclude canceled edges from lease reachability and retain canceled-parent checks - make review, provider timestamp, and GitHub 404 handling deterministic - preserve GitHub rate-limit category and retry metadata Rationale: - canceled descendants must not hold ancestor resources after reparenting - provider timestamps and review IDs need deterministic, precision-aware fencing - inaccessible repositories and rate limits must remain observable to recovery Tests: - cargo test-system-duckdb --test scheduler --quiet - cargo test-system-duckdb --lib --quiet - cargo test-system-duckdb --test hierarchy_selection --quiet - cargo test-system-duckdb --test gateway --quiet - cargo test-system-duckdb --test action_handler --quiet - cargo check-system-duckdb --all-targets - cargo clippy-system-duckdb - npm run type-check - cargo fmt --all -- --check - git diff --check * fix(hierarchy): fence prepared parent merge evidence Persist required merge-result commits with parent dispatch intent and verify them against the prepared checkout before launch. Keep ambiguous second-precision provider timestamps fenced and replay failed generation rebinding from the pre-resolution execution state. Tests: cargo test-system-duckdb --test scheduler --quiet; cargo test-system-duckdb --lib --quiet; cargo test-system-duckdb --test hierarchy_selection --quiet; cargo test-system-duckdb --test gateway --quiet; cargo test-system-duckdb --test action_handler --quiet; cargo check-system-duckdb --all-targets; cargo clippy-system-duckdb; npm run type-check; cargo fmt --all -- --check; git diff --check * fix(hierarchy): keep merge evidence repository-bound Retain repository identity alongside provider merge-result commits and keep legacy durable state readable. Remove scheduler verification against the non-repository parent root so integration worktree preparation remains owned by the parent integration slice. Tests: cargo test-system-duckdb --lib --quiet; cargo test-system-duckdb --test scheduler --quiet; cargo test-system-duckdb --test hierarchy_selection --quiet; cargo test-system-duckdb --test gateway --quiet; cargo test-system-duckdb --test action_handler --quiet; cargo check-system-duckdb --all-targets; cargo clippy-system-duckdb; npm run type-check; cargo fmt --all -- --check; git diff --check * fix(hierarchy): expose replan contract and bound provider work * fix(hierarchy): bound evidence hydration and lifecycle action replay Give retry, cancel, resume, and rehydrate requests fresh operation IDs, with explicit key reuse for retries of the same request. Fetch only the latest 32 PR-shaped Linear attachments in issue queries and remove historical attachment pagination. Stop subsequent parent provider lookups when a prior lookup activates the shared cooldown. Validation: 1294 library, 101 scheduler, 41 Linear, and 132 API-client tests passed; TypeScript, Clippy, formatting, and diff checks passed. * fix(hierarchy): retain reparented evidence and scheduler authority Preserve required descendants and their review/ancestor owners when a subtree moves, and remove later leaf releases that ignore tracker edges. Derive parent terminal eligibility from scheduler-owned state and clear stale success receipts before reopened work enters a launch batch. Add regressions for both release paths, canceled edges, forged provider flags, reactivation ordering, and persisted receipt invalidation. Validation: 1526 Rust tests passed across library and five integration suites; the hierarchy fixture validation check also passed. Clippy, formatting, and git diff --check passed. Two review passes reproduced and addressed the original findings and three follow-up defects. * fix(hierarchy): defer partial releases and retain API PR links Keep existing scopes and leases during partial hierarchy changes and request a full observation on the next tick. Replans fetch reachability before reconciliation, preserving retry semantics on rate limits. Accept API-created canonical PR attachments during Linear normalization. Validation: library, scheduler, Linear client, workspace manager, and action-handler suites passed (1489 tests, one live test ignored). Both findings have regressions that failed before remediation. Clippy, formatting, and git diff --check passed. --- apps/desktop/src/index.ts | 24 +- apps/web/src/index.ts | 24 +- crates/opensymphony-cli/src/lib.rs | 12 + crates/opensymphony-cli/src/memory.rs | 1 + .../src/orchestrator_run/backends.rs | 2180 +++++++++++++- .../src/orchestrator_run/config.rs | 207 +- .../src/orchestrator_run/mod.rs | 180 +- .../src/orchestrator_run/snapshot.rs | 31 +- crates/opensymphony-cli/tests/tui.rs | 2 + crates/opensymphony-control/src/lib.rs | 2 + .../tests/control_plane.rs | 2 + .../opensymphony-domain/src/control_plane.rs | 4 + crates/opensymphony-domain/src/issue.rs | 2 + crates/opensymphony-domain/src/lib.rs | 6 +- crates/opensymphony-domain/src/snapshot.rs | 11 + .../opensymphony-domain/src/state_machine.rs | 8 + crates/opensymphony-domain/src/tracker.rs | 20 + .../tests/snapshot_serialization.rs | 2 + .../opensymphony-gateway-schema/src/action.rs | 8 + crates/opensymphony-gateway-schema/src/run.rs | 10 + .../tests/gateway_schema.rs | 3 + .../src/action_handler.rs | 145 +- crates/opensymphony-gateway/src/lib.rs | 62 +- crates/opensymphony-gateway/tests/gateway.rs | 88 +- crates/opensymphony-linear/src/client.rs | 63 +- crates/opensymphony-linear/src/error.rs | 20 +- crates/opensymphony-linear/src/graphql.rs | 111 +- crates/opensymphony-linear/src/normalize.rs | 93 +- .../src/task_graph_cache.rs | 1 + .../tests/linear_client.rs | 77 +- crates/opensymphony-openhands/src/session.rs | 23 +- .../tests/issue_session_runner.rs | 1 + .../tests/live_local_suite.rs | 1 + .../src/hierarchy.rs | 2665 +++++++++++++++++ crates/opensymphony-orchestrator/src/lib.rs | 7 + .../src/scheduler.rs | 2366 ++++++++++++++- .../src/selection.rs | 2 + .../tests/scheduler.rs | 937 +++++- crates/opensymphony-tui/src/lib.rs | 2 + crates/opensymphony-tui/tests/reducer.rs | 4 + crates/opensymphony-workspace/src/manager.rs | 125 + crates/opensymphony-workspace/src/models.rs | 15 + .../tests/workspace_manager.rs | 4 + docs/architecture.md | 16 + docs/configuration.md | 4 +- docs/linear-and-tools.md | 13 +- docs/operations.md | 8 + docs/remote-stream-protocol-decisions.md | 6 +- docs/specs/multi-repo-orchestration-spec.md | 2 +- docs/testing-and-operations.md | 2 +- docs/workspace-and-lifecycle.md | 23 + .../api-client/__tests__/run-actions.test.ts | 64 +- packages/api-client/src/index.ts | 23 +- packages/api-client/src/mock.ts | 31 +- packages/api-client/src/transports.ts | 31 +- packages/gateway-schema/src/action.ts | 1 + tests/action_handler.rs | 36 +- tests/task_graph_mutations_routes.rs | 2 + 58 files changed, 9451 insertions(+), 362 deletions(-) create mode 100644 crates/opensymphony-orchestrator/src/hierarchy.rs diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts index 303b1c799..89713004e 100644 --- a/apps/desktop/src/index.ts +++ b/apps/desktop/src/index.ts @@ -334,20 +334,28 @@ class DesktopTransportAdapter implements TauriTransportAdapter { return this.actionInner.dispatchAction(action); } - cancelRun(runId: string): Promise { - return this.actionInner.cancelRun(runId); + cancelRun(runId: string, operationId?: string): Promise { + return this.actionInner.cancelRun(runId, operationId); } - retryRun(runId: string): Promise { - return this.actionInner.retryRun(runId); + replanParent( + parentId: string, + hierarchyGeneration: number, + operationId?: string, + ): Promise { + return this.actionInner.replanParent(parentId, hierarchyGeneration, operationId); + } + + retryRun(runId: string, operationId?: string): Promise { + return this.actionInner.retryRun(runId, operationId); } - resumeRun(runId: string): Promise { - return this.actionInner.resumeRun(runId); + resumeRun(runId: string, operationId?: string): Promise { + return this.actionInner.resumeRun(runId, operationId); } - rehydrateRun(runId: string): Promise { - return this.actionInner.rehydrateRun(runId); + rehydrateRun(runId: string, operationId?: string): Promise { + return this.actionInner.rehydrateRun(runId, operationId); } commentRun(runId: string, text: string): Promise { diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 9f7530803..2c6140c2e 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -111,20 +111,28 @@ class BrowserTransport implements BrowserTransportAdapter { return this.inner.dispatchAction(action); } - cancelRun(runId: string): Promise { - return this.inner.cancelRun(runId); + cancelRun(runId: string, operationId?: string): Promise { + return this.inner.cancelRun(runId, operationId); } - retryRun(runId: string): Promise { - return this.inner.retryRun(runId); + replanParent( + parentId: string, + hierarchyGeneration: number, + operationId?: string, + ): Promise { + return this.inner.replanParent(parentId, hierarchyGeneration, operationId); + } + + retryRun(runId: string, operationId?: string): Promise { + return this.inner.retryRun(runId, operationId); } - resumeRun(runId: string): Promise { - return this.inner.resumeRun(runId); + resumeRun(runId: string, operationId?: string): Promise { + return this.inner.resumeRun(runId, operationId); } - rehydrateRun(runId: string): Promise { - return this.inner.rehydrateRun(runId); + rehydrateRun(runId: string, operationId?: string): Promise { + return this.inner.rehydrateRun(runId, operationId); } commentRun(runId: string, text: string): Promise { diff --git a/crates/opensymphony-cli/src/lib.rs b/crates/opensymphony-cli/src/lib.rs index fe9ec2ee5..7fce13e60 100644 --- a/crates/opensymphony-cli/src/lib.rs +++ b/crates/opensymphony-cli/src/lib.rs @@ -1010,6 +1010,7 @@ async fn run_doctor_rehydration( }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: vec![], project_id: None, @@ -2083,6 +2084,8 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), @@ -2128,6 +2131,8 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("https://agent.example.com/runtime".to_owned()), @@ -2181,6 +2186,8 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("https://agent.example.com/runtime".to_owned()), @@ -2536,6 +2543,7 @@ async fn run_rehydrate_command(args: RehydrateArgs) -> Result<(), String> { }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: vec![], project_id: None, @@ -2955,11 +2963,15 @@ mod tests { credential_kind: "ssh-agent".to_owned(), credential_reference: None, credential_env: None, + review_credential_env: None, instructions_path: PathBuf::from("AGENTS.md"), policy_generation: "scheduler-policy".to_owned(), review_profile: "default".to_owned(), review_provider: "git".to_owned(), review_policy_generation: "review-policy".to_owned(), + required_checks: false, + required_review: false, + merge_method: None, }, )]); diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index 7a19d41af..d0023c81e 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -14127,6 +14127,7 @@ Public memory concept. interrupt_reason: None, status: RunStatus::Succeeded, created_at: now, + started_at: Some(now), updated_at: now, status_detail: None, hooks: Vec::new(), diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 88751f74e..5058898a0 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -1,11 +1,13 @@ //! Runtime backend adapters for tracker, workspace, and worker orchestration. +use futures_util::{StreamExt, stream}; +use serde::de::DeserializeOwned; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, env, io, path::{Path, PathBuf}, sync::{Arc, Mutex}, - time::{Duration, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use crate::opensymphony_cli::{ @@ -20,10 +22,11 @@ use crate::opensymphony_codex::{ turn_status, }; use crate::opensymphony_domain::{ - ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, IssueIdentifier, - IssueState, IssueStateCategory, NormalizedIssue, RepositoryBindingOutcome, RetryEntry, - RetryReason, RuntimeStreamState, TimestampMs, TrackerErrorCategory, TrackerIssue, - TrackerIssueSummary, WorkerOutcomeKind, WorkerOutcomeRecord, WorkspaceKey, + CanonicalRepositoryId, ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, + IssueIdentifier, IssueState, IssueStateCategory, NormalizedIssue, RepositoryBindingOutcome, + RepositoryRouting, RetryEntry, RetryReason, RuntimeStreamState, TimestampMs, + TrackerErrorCategory, TrackerIssue, TrackerIssueSummary, WorkerOutcomeKind, + WorkerOutcomeRecord, WorkspaceKey, }; use crate::opensymphony_linear::{LinearClient, LinearConfig, LinearError, WorkpadComment}; use crate::opensymphony_openhands::{ @@ -36,16 +39,19 @@ use crate::opensymphony_openhands::{ pending_conversation_manifest_path, superseded_conversation_manifests_path, }; use crate::opensymphony_orchestrator::{ - RecoveredRun, RecoveryRecord, RetryExhaustionRecord, RetryPendingRecord, TrackerBackend, + ChildEligibilityEvidence, DurableOrchestratorState, HierarchySnapshot, LeaseResource, + ParentEligibilityEvidence, ProviderEvidenceBoundary, RecoveredRun, RecoveryRecord, + RequiredMergeCommit, RetryExhaustionRecord, RetryPendingRecord, TrackerBackend, WorkerAbortReason, WorkerBackend, WorkerInterruptAcknowledgement, WorkerLaunch, WorkerStartRequest, WorkerUpdate, WorkspaceBackend, }; use crate::opensymphony_workflow::{Environment, ProcessEnvironment, ResolvedWorkflow}; use crate::opensymphony_workspace::{ - CleanupConfig, HookConfig, HookDefinition, IssueDescriptor, IssueLifecycleState, RunDescriptor, - RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, WorkspaceHandle, - WorkspaceManager, WorkspaceManagerConfig, checkout_credential_environment_variables, - compose_terminal_prompt, environment_variable_names_equal, + CheckoutRepository, CleanupConfig, HookConfig, HookDefinition, IssueDescriptor, + IssueLifecycleState, RunDescriptor, RunManifest, RunStatus, TerminalRuntimeEnvelope, + WorkspaceError, WorkspaceHandle, WorkspaceManager, WorkspaceManagerConfig, + checkout_credential_environment_variables, compose_terminal_prompt, + environment_variable_names_equal, }; use async_trait::async_trait; use thiserror::Error; @@ -67,6 +73,8 @@ use super::{ const DEFAULT_WORKER_LAUNCH_TIMEOUT: Duration = Duration::from_secs(60); const CODEX_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); const CODEX_WORKER_LAUNCH_TIMEOUT: Duration = Duration::from_secs(75); +const PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY: usize = 8; +const MAX_PARENT_PULL_REQUEST_EVIDENCE_CANDIDATES: usize = 32; const CODEX_SCHEMA_GENERATION_TIMEOUT: Duration = Duration::from_secs(30); const CODEX_TERMINAL_TIMEOUT: Duration = Duration::from_secs(300); const CODEX_STDERR_TAIL_LINES: usize = 20; @@ -87,6 +95,8 @@ pub(super) enum CliWorkspaceError { ConversationLifecycle(String), #[error("retry state persistence failed: {0}")] RetryState(String), + #[error("workspace cleanup deferred while a durable lease is active")] + CleanupDeferred, } #[derive(Debug, Error)] @@ -107,14 +117,25 @@ pub(super) enum CliWorkerError { #[derive(Debug)] enum LaunchReport { - Conversation(Box), + Conversation { + conversation: Box, + started_at: Option, + }, Failed(String), } pub(super) struct RuntimeTrackerBackend { client: LinearClient, + github_http: reqwest::Client, + github_token: Option, + repository_checkouts: BTreeMap, + repository_routing: Option, + active_states: HashSet, + terminal_states: HashSet, } +const GITHUB_ELIGIBILITY_TIMEOUT: Duration = Duration::from_secs(15); + #[derive(Debug, Default, Clone, PartialEq, Eq)] pub(super) struct ActiveConversationStorePreparation { pub moved: usize, @@ -150,6 +171,7 @@ pub(super) struct RuntimeWorkspaceBackend { active_states: HashSet, terminal_states: HashSet, terminal_cleanup_paths: HashSet, + recovered_run_started_at: BTreeMap, codex_bin: String, retain_failed: bool, retry_state_root: PathBuf, @@ -253,8 +275,19 @@ impl WorkpadCommentSource for LinearWorkpadCommentSource { impl IssueSessionObserver for SchedulerObserver { fn on_launch(&mut self, conversation: &ConversationMetadata) { + self.on_launch_with_started_at(conversation, None); + } + + fn on_launch_with_started_at( + &mut self, + conversation: &ConversationMetadata, + started_at: Option, + ) { if let Some(sender) = self.launch_tx.take() { - let _ = sender.send(LaunchReport::Conversation(Box::new(conversation.clone()))); + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(conversation.clone()), + started_at, + }); } } @@ -315,9 +348,35 @@ fn workpad_comment_from_linear(comment: WorkpadComment) -> SessionWorkpadComment pub(super) fn build_tracker_backend( workflow: &ResolvedWorkflow, + repository_checkouts: BTreeMap, + repository_routing: Option, ) -> Result { + let github_http = reqwest::Client::builder() + .timeout(GITHUB_ELIGIBILITY_TIMEOUT) + .build() + .map_err(|error| LinearError::InvalidConfiguration(format!("GitHub client: {error}")))?; Ok(RuntimeTrackerBackend { client: build_linear_client(workflow)?, + github_http, + github_token: env::var("GITHUB_TOKEN") + .ok() + .filter(|token| !token.trim().is_empty()), + repository_checkouts, + repository_routing, + active_states: workflow + .config + .tracker + .active_states + .iter() + .map(|state| normalized_state_name(state)) + .collect(), + terminal_states: workflow + .config + .tracker + .terminal_states + .iter() + .map(|state| normalized_state_name(state)) + .collect(), }) } @@ -860,58 +919,1437 @@ fn runtime_checkout_env_remove( } fn checkout_env_remove_variables( - variables: BTreeSet, + mut variables: BTreeSet, _local_server_env: &BTreeMap, ) -> BTreeSet { // A local-server override must never reintroduce a checkout credential, // including when its value was resolved from that same environment // variable (for example `${GITHUB_TOKEN}`). + // The tracker backend may use this ambient fallback for provider reads; + // it must never cross the worker boundary when it was not explicitly + // configured as a worker credential. + variables.insert("GITHUB_TOKEN".to_owned()); variables } -fn strict_openhands_cleanup_requires_conversation_store( - strict_checkout: bool, - manifest: &IssueConversationManifest, - store: Option<&OpenHandsConversationStorePaths>, -) -> bool { - strict_checkout && !conversation_manifest_is_codex(manifest) && store.is_none() +fn strict_openhands_cleanup_requires_conversation_store( + strict_checkout: bool, + manifest: &IssueConversationManifest, + store: Option<&OpenHandsConversationStorePaths>, +) -> bool { + strict_checkout && !conversation_manifest_is_codex(manifest) && store.is_none() +} + +impl TrackerBackend for RuntimeTrackerBackend { + type Error = LinearError; + + async fn parent_eligibility( + &mut self, + _parent: &TrackerIssue, + hierarchy: &HierarchySnapshot, + ) -> Result { + let identifiers = hierarchy + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_identifier.as_str().to_owned()) + .collect::>(); + let children = self.client.issues_by_identifiers(&identifiers).await?; + let mut evidence = Vec::with_capacity(hierarchy.required_child_edges.len()); + for edge in hierarchy + .required_child_edges + .iter() + .filter(|edge| edge.required) + { + let child = children + .iter() + .find(|child| child.id == edge.child_id.as_str()) + .ok_or_else(|| LinearError::MissingIssueIds { + issue_ids: vec![edge.child_identifier.as_str().to_owned()], + })?; + let ( + provider_merge_confirmed, + merge_result_commit, + merge_repository_id, + merge_repository_ids, + merge_result_commits, + provider_evidence_at, + merge_required, + provider_evidence_by_issue, + ) = if let Some(repository) = self.checkout_policy_for_issue(child) { + if !repository.provider.eq_ignore_ascii_case("github") { + // Legacy single-repository profiles may use a generic Git + // checkout without a provider API for merge evidence. They + // retain the legacy leaf-completion path instead of being + // routed through incompatible GitHub evidence. + ( + false, + None, + None, + Vec::new(), + Vec::new(), + None, + false, + Vec::new(), + ) + } else { + let ( + provider_merge_confirmed, + merge_result_commit, + merge_repository_id, + merge_repository_ids, + merge_result_commits, + provider_evidence_at, + provider_evidence_by_issue, + ) = self.direct_merge_evidence(child, repository).await?; + ( + provider_merge_confirmed, + merge_result_commit, + merge_repository_id, + merge_repository_ids, + merge_result_commits, + provider_evidence_at, + true, + provider_evidence_by_issue, + ) + } + } else if child.sub_issues.is_empty() { + ( + false, + None, + None, + Vec::new(), + Vec::new(), + None, + true, + Vec::new(), + ) + } else { + self.descendant_merge_evidence(child).await? + }; + evidence.push(ChildEligibilityEvidence { + child_id: edge.child_id.clone(), + hierarchy_generation: hierarchy.generation, + // The scheduler overlays this provider evidence with its + // own durable terminal outcome for the child execution. + orchestrator_terminal: false, + provider_merge_confirmed, + merge_required, + merge_result_commit, + merge_result_commits: merge_result_commits + .iter() + .map(|commit| commit.commit.clone()) + .collect(), + merge_result_commits_by_repository: merge_result_commits, + merge_repository_id, + merge_repository_ids, + provider_evidence_at, + provider_evidence_by_issue, + resource: None, + resources: Vec::new(), + unresolved_failure: None, + }); + } + Ok(ParentEligibilityEvidence { + hierarchy_generation: hierarchy.generation, + children: evidence, + }) + } + + async fn candidate_issues(&mut self) -> Result, Self::Error> { + self.client.candidate_issues().await + } + + async fn candidate_issue_summaries(&mut self) -> Result, Self::Error> { + self.client.candidate_issue_summaries().await + } + + async fn terminal_issues(&mut self) -> Result, Self::Error> { + self.client.terminal_issues().await + } + + async fn issues_by_identifiers( + &mut self, + identifiers: &[String], + ) -> Result, Self::Error> { + self.client.issues_by_identifiers(identifiers).await + } + + async fn issue_states_by_ids( + &mut self, + issue_ids: &[String], + ) -> Result, Self::Error> { + self.client.issue_states_by_ids(issue_ids).await + } + + fn error_category(error: &Self::Error) -> Option { + Some(error.category()) + } + + fn retry_after(error: &Self::Error) -> Option { + error.retry_after() + } +} + +impl RuntimeTrackerBackend { + fn checkout_policy_for_issue(&self, issue: &TrackerIssue) -> Option<&CheckoutRepository> { + if !issue.sub_issues.is_empty() { + return None; + } + if let Some(routing) = self.repository_routing.as_ref() { + let binding = routing.resolve( + &issue.labels, + issue.project_id.as_deref(), + issue.project_slug.as_deref(), + false, + ); + return binding + .repository_id() + .and_then(|repository_id| self.repository_checkouts.get(repository_id.as_str())); + } + (self.repository_checkouts.len() == 1) + .then(|| self.repository_checkouts.values().next()) + .flatten() + } + + async fn github_merge_evidence( + &self, + pr_url: &str, + repository: &CheckoutRepository, + expected_head_branch: Option<&str>, + ) -> Result, LinearError> { + let url = Url::parse(pr_url).map_err(|error| { + LinearError::InvalidResponse(format!("invalid GitHub pull request URL: {error}")) + })?; + let review_provider = if repository.review_provider.trim().is_empty() { + repository.provider.as_str() + } else { + repository.review_provider.as_str() + }; + if !review_provider.eq_ignore_ascii_case("github") + || !repository.provider.eq_ignore_ascii_case("github") + { + return Ok(Some(GithubMergeEvidence::incompatible())); + } + if url.scheme() != "https" { + return Err(LinearError::InvalidResponse(format!( + "GitHub pull request URL must use https: {pr_url}" + ))); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(LinearError::InvalidResponse(format!( + "GitHub pull request URL must not contain credentials: {pr_url}" + ))); + } + let segments = url + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + if segments.len() != 4 || segments[2] != "pull" { + return Err(LinearError::InvalidResponse(format!( + "invalid GitHub pull request path: {pr_url}" + ))); + } + let pull_number = segments[3].parse::().map_err(|error| { + LinearError::InvalidResponse(format!( + "invalid GitHub pull request number `{}`: {error}", + segments[3] + )) + })?; + let authority = github_url_authority(&url).ok_or_else(|| { + LinearError::InvalidResponse(format!( + "GitHub pull request URL has no supported authority: {pr_url}" + )) + })?; + let configured_authority = + github_remote_authority(&repository.remote_locator).ok_or_else(|| { + LinearError::InvalidResponse(format!( + "configured GitHub remote has no authority: {}", + repository.remote_locator + )) + })?; + if authority != configured_authority { + return Ok(Some(GithubMergeEvidence::incompatible())); + } + if let Some((configured_owner, configured_repository)) = + github_remote_repository(&repository.remote_locator) + && (!configured_owner.eq_ignore_ascii_case(segments[0]) + || !configured_repository.eq_ignore_ascii_case(segments[1])) + { + return Ok(Some(GithubMergeEvidence::incompatible())); + } + let public_github = authority == "github.com"; + let api_root = if public_github { + "https://api.github.com".to_owned() + } else { + format!("{}/api/v3", url.origin().ascii_serialization()) + }; + let endpoint = format!( + "{api_root}/repos/{}/{}/pulls/{}", + segments[0], segments[1], segments[3] + ); + let merge_repository_id = CanonicalRepositoryId::from_remote( + "github", + repository.provider_id.as_deref(), + format!("https://{authority}/{}/{}", segments[0], segments[1]), + ) + .map_err(|error| { + LinearError::InvalidResponse(format!("invalid GitHub repository identity: {error}")) + })?; + let pull_request = match self + .github_get_json::(&endpoint, repository) + .await + { + Ok(pull_request) => pull_request, + Err(LinearError::HttpStatus { status, .. }) + if status == reqwest::StatusCode::NOT_FOUND => + { + // A PR lookup 404 is only historical-deletion evidence after + // the repository itself is confirmed readable. Otherwise the + // same response can mean a private repository or insufficient + // token scope, which must remain an operational failure. + let repository_endpoint = + format!("{api_root}/repos/{}/{}", segments[0], segments[1]); + self.github_get_json::(&repository_endpoint, repository) + .await?; + return Ok(None); + } + Err(error) => return Err(error), + }; + // `updated_at` changes when an old PR is edited after a child is + // reactivated. Bind eligibility to the immutable merge event instead + // so unrelated metadata edits cannot make stale evidence look fresh. + let provider_evidence_at = pull_request + .merged_at + .as_deref() + .or(Some(pull_request.created_at.as_str())) + .and_then(github_provider_evidence_timestamp_ms); + let compatible = pull_request.base.ref_name == repository.target_branch + && pull_request + .base + .repo + .full_name + .eq_ignore_ascii_case(&format!("{}/{}", segments[0], segments[1])) + && repository.provider_id.as_deref().is_none_or(|provider_id| { + pull_request + .base + .repo + .native_ids() + .iter() + .any(|candidate| candidate == provider_id) + }) + && expected_head_branch.is_none_or(|expected| pull_request.head.ref_name == expected); + let merge_method_satisfied = if compatible && pull_request.merged_at.is_some() { + let Some(satisfied) = self + .github_merge_method_satisfied( + &api_root, + segments[0], + segments[1], + pull_request.merge_commit_sha.as_deref(), + repository, + ) + .await? + else { + return Ok(None); + }; + satisfied + } else { + false + }; + let merge_commit_reachable = if compatible && pull_request.merged_at.is_some() { + let Some(reachable) = self + .github_merge_commit_reachable( + &api_root, + segments[0], + segments[1], + &repository.target_branch, + pull_request.merge_commit_sha.as_deref(), + repository, + ) + .await? + else { + return Ok(None); + }; + reachable + } else { + false + }; + let policy_satisfied = if compatible && pull_request.merged_at.is_some() { + merge_method_satisfied + && self + .github_merge_policy_satisfied( + &api_root, + segments[0], + segments[1], + segments[3], + repository, + pull_request.head.sha.as_deref(), + ) + .await? + } else { + false + }; + Ok(Some(GithubMergeEvidence { + compatible, + merged: compatible + && merge_method_satisfied + && merge_commit_reachable + && policy_satisfied + && pull_request.merged_at.is_some() + && pull_request + .merge_commit_sha + .as_deref() + .is_some_and(|commit| !commit.trim().is_empty()), + merge_commit_sha: pull_request.merge_commit_sha, + merge_repository_id: Some(merge_repository_id), + created_at: pull_request.created_at, + pull_number, + provider_evidence_at, + })) + } + + async fn github_merge_method_satisfied( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + merge_commit_sha: Option<&str>, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + let Some(expected_method) = repository + .merge_method + .as_deref() + .map(str::trim) + .filter(|method| !method.is_empty()) + else { + return Ok(Some(true)); + }; + let Some(merge_commit_sha) = merge_commit_sha.filter(|sha| !sha.trim().is_empty()) else { + return Ok(Some(false)); + }; + match expected_method.to_ascii_lowercase().as_str() { + "merge" => { + let endpoint = format!( + "{api_root}/repos/{owner}/{repository_name}/commits/{merge_commit_sha}" + ); + let commit = match self + .github_get_json::(&endpoint, repository) + .await + { + Ok(commit) => commit, + Err(LinearError::HttpStatus { status, .. }) + if status == reqwest::StatusCode::NOT_FOUND => + { + let repository_endpoint = + format!("{api_root}/repos/{owner}/{repository_name}"); + self.github_get_json::(&repository_endpoint, repository) + .await?; + return Ok(None); + } + Err(error) => return Err(error), + }; + Ok(Some(github_merge_method_matches( + expected_method, + commit.parents.len(), + ))) + } + "squash" | "rebase" => Err(LinearError::InvalidResponse(format!( + "GitHub REST merge evidence cannot distinguish `{expected_method}` from the other single-parent merge method; configure merge_method: merge or omit merge_method" + ))), + _ => Ok(Some(false)), + } + } + + async fn github_merge_commit_reachable( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + target_branch: &str, + merge_commit_sha: Option<&str>, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + let Some(merge_commit_sha) = merge_commit_sha.filter(|sha| !sha.trim().is_empty()) else { + return Ok(Some(false)); + }; + let mut endpoint = Url::parse(api_root).map_err(|error| { + LinearError::InvalidResponse(format!("invalid GitHub API root: {error}")) + })?; + { + let mut segments = endpoint.path_segments_mut().map_err(|_| { + LinearError::InvalidResponse("GitHub API root cannot be a base URL".to_owned()) + })?; + segments + .push("repos") + .push(owner) + .push(repository_name) + .push("compare") + .push(&format!("{target_branch}...{merge_commit_sha}")); + } + let comparison = self + .github_get_json::(endpoint.as_ref(), repository) + .await?; + Ok(Some(github_compare_contains_commit(&comparison))) + } + + async fn github_get_json( + &self, + endpoint: &str, + repository: &CheckoutRepository, + ) -> Result { + let mut request = self + .github_http + .get(endpoint) + .header(reqwest::header::USER_AGENT, "opensymphony-orchestrator") + .header(reqwest::header::ACCEPT, "application/vnd.github+json"); + let configured_token = match repository.review_credential_env.as_deref() { + Some(name) => { + let token = env::var(name).map_err(|_| { + LinearError::InvalidConfiguration(format!( + "configured GitHub review credential variable `{name}` is not set" + )) + })?; + (!token.trim().is_empty()).then_some(token).ok_or_else(|| { + LinearError::InvalidConfiguration(format!( + "configured GitHub review credential variable `{name}` is empty" + )) + })? + } + None => self.github_token.clone().unwrap_or_default(), + }; + if !configured_token.is_empty() { + request = request.bearer_auth(configured_token); + } + let response = request + .send() + .await + .map_err(|error| LinearError::Request(Box::new(error)))?; + let status = response.status(); + let retry_after = github_retry_after(response.headers()); + let headers_indicate_rate_limit = github_headers_indicate_rate_limit(response.headers()); + let response_body = response + .text() + .await + .map_err(|error| LinearError::Request(Box::new(error)))?; + if !status.is_success() { + let rate_limited = headers_indicate_rate_limit + || response_body.to_ascii_lowercase().contains("rate limit") + || response_body + .to_ascii_lowercase() + .contains("secondary rate limit"); + let body = if rate_limited { + format!("GitHub API rate limit response for {endpoint}: {response_body}") + } else { + format!("GitHub API lookup failed for {endpoint}: {response_body}") + }; + return Err(LinearError::HttpStatus { + status, + body, + retry_after, + }); + } + serde_json::from_str::(&response_body).map_err(|error| { + LinearError::InvalidResponse(format!( + "GitHub API response decode failed for {endpoint}: {error}" + )) + }) + } + + async fn github_merge_policy_satisfied( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + pull_number: &str, + repository: &CheckoutRepository, + check_commit_sha: Option<&str>, + ) -> Result { + if repository.required_review { + let reviews = self + .github_reviews(api_root, owner, repository_name, pull_number, repository) + .await?; + let latest_by_reviewer = latest_github_review_states(reviews); + if !latest_by_reviewer + .values() + .any(|(state, _, _)| state.eq_ignore_ascii_case("approved")) + || latest_by_reviewer + .values() + .any(|(state, _, _)| state.eq_ignore_ascii_case("changes_requested")) + { + return Ok(false); + } + } + if repository.required_checks { + let Some(check_commit_sha) = check_commit_sha.filter(|sha| !sha.trim().is_empty()) + else { + return Ok(false); + }; + let (total_count, check_runs) = self + .github_check_runs( + api_root, + owner, + repository_name, + check_commit_sha, + repository, + ) + .await?; + let required_checks = self + .github_required_check_contexts(api_root, owner, repository_name, repository) + .await?; + let commit_statuses = if required_checks.is_some() { + self.github_commit_statuses( + api_root, + owner, + repository_name, + check_commit_sha, + repository, + ) + .await? + } else { + Vec::new() + }; + if check_runs.len() < total_count + || (total_count == 0 && required_checks.is_none()) + || !required_check_evidence_satisfied( + &check_runs, + &commit_statuses, + required_checks.as_ref(), + ) + { + return Ok(false); + } + } + Ok(true) + } + + async fn github_reviews( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + pull_number: &str, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + let mut page = 1; + let mut reviews = Vec::new(); + loop { + let endpoint = format!( + "{api_root}/repos/{owner}/{repository_name}/pulls/{pull_number}/reviews?per_page=100&page={page}" + ); + let page_reviews = self + .github_get_json::>(&endpoint, repository) + .await?; + let page_count = page_reviews.len(); + reviews.extend(page_reviews); + if page_count == 0 || page_count < 100 || page >= 1000 { + return Ok(reviews); + } + page += 1; + } + } + + async fn github_check_runs( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + merge_commit_sha: &str, + repository: &CheckoutRepository, + ) -> Result<(usize, Vec), LinearError> { + let mut page = 1; + let mut total_count = None; + let mut check_runs = Vec::new(); + loop { + let endpoint = format!( + "{api_root}/repos/{owner}/{repository_name}/commits/{merge_commit_sha}/check-runs?per_page=100&page={page}" + ); + let response = self + .github_get_json::(&endpoint, repository) + .await?; + total_count.get_or_insert(response.total_count); + let page_count = response.check_runs.len(); + check_runs.extend(response.check_runs); + let expected = total_count.unwrap_or_default(); + if check_runs.len() >= expected { + return Ok((expected, check_runs)); + } + if page_count == 0 || page >= 1000 { + return Ok((expected, check_runs)); + } + page += 1; + } + } + + async fn github_commit_statuses( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + commit_sha: &str, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + let mut page = 1; + let mut total_count = None; + let mut statuses = Vec::new(); + loop { + let endpoint = format!( + "{api_root}/repos/{owner}/{repository_name}/commits/{commit_sha}/status?per_page=100&page={page}" + ); + let response = self + .github_get_json::(&endpoint, repository) + .await?; + total_count.get_or_insert(response.total_count); + let page_count = response.statuses.len(); + statuses.extend(response.statuses); + let expected = total_count.unwrap_or_default(); + if statuses.len() >= expected || page_count == 0 || page >= 1000 { + return Ok(statuses); + } + page += 1; + } + } + + async fn github_required_check_contexts( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + let endpoint = github_required_status_checks_endpoint( + api_root, + owner, + repository_name, + &repository.target_branch, + )?; + match self + .github_get_json::(&endpoint, repository) + .await + { + Ok(policy) => { + Ok((!policy.contexts.is_empty() || !policy.checks.is_empty()).then_some(policy)) + } + // A 404 is ambiguous: GitHub returns it for an unprotected branch + // and for credentials that cannot read protection settings. + // Required-check eligibility therefore fails closed. + Err(LinearError::HttpStatus { status, .. }) + if status == reqwest::StatusCode::NOT_FOUND => + { + Err(LinearError::HttpStatus { + status, + body: "GitHub branch protection lookup was not authorized or unavailable: /protection/required_status_checks".to_owned(), + retry_after: None, + }) + } + Err(error) => Err(error), + } + } + + async fn direct_merge_evidence( + &self, + issue: &TrackerIssue, + repository: &CheckoutRepository, + ) -> Result< + ( + bool, + Option, + Option, + Vec, + Vec, + Option, + Vec, + ), + LinearError, + > { + let pull_requests = parent_pull_request_candidates(issue); + let evidence = stream::iter(pull_requests) + .map(|pr_url| async move { + self.github_merge_evidence(&pr_url, repository, issue.branch_name.as_deref()) + .await + }) + .buffer_unordered(PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY) + .collect::>() + .await + .into_iter() + .collect::, _>>()? + .into_iter() + .flatten() + .collect::>(); + let (confirmed, commit, repository_id, provider_evidence_at) = + select_current_github_merge_evidence(evidence); + let commits = commit + .iter() + .zip(repository_id.iter()) + .map(|(commit, repository_id)| RequiredMergeCommit { + repository_id: Some(repository_id.clone()), + commit: commit.clone(), + }) + .collect(); + let repository_ids = repository_id.iter().cloned().collect(); + let provider_evidence_by_issue = provider_evidence_at + .map(|evidence_at| { + vec![ProviderEvidenceBoundary { + issue_id: IssueId::new(issue.id.clone()).expect("tracker ids are validated"), + evidence_at, + }] + }) + .unwrap_or_default(); + Ok(( + confirmed, + commit, + repository_id, + repository_ids, + commits, + provider_evidence_at, + provider_evidence_by_issue, + )) + } + + async fn descendant_merge_evidence( + &self, + parent: &TrackerIssue, + ) -> Result< + ( + bool, + Option, + Option, + Vec, + Vec, + Option, + bool, + Vec, + ), + LinearError, + > { + let mut pending = parent.sub_issues.clone(); + let mut commits = Vec::new(); + let mut repository_ids = BTreeSet::new(); + let mut provider_evidence_at: Option = None; + let mut provider_evidence_by_issue = Vec::new(); + let mut saw_leaf = false; + while !pending.is_empty() { + let identifiers = pending + .drain(..) + .map(|child| child.identifier) + .collect::>(); + let children = self.client.issues_by_identifiers(&identifiers).await?; + let mut leaf_children = Vec::new(); + for child in children { + let child_state = normalized_state_name(&child.state); + if self.active_states.contains(&child_state) + || !self.terminal_states.contains(&child_state) + { + return Ok(( + false, + None, + None, + Vec::new(), + Vec::new(), + None, + true, + Vec::new(), + )); + } + if matches!( + child.state_kind, + crate::opensymphony_domain::TrackerIssueStateKind::Canceled + ) || (self.terminal_states.contains(&child_state) + && child_state.contains("cancel")) + { + continue; + } + if let Some(repository) = self.checkout_policy_for_issue(&child) { + saw_leaf = true; + leaf_children.push((child, repository.clone())); + } else if child.sub_issues.is_empty() { + return Ok(( + false, + None, + None, + Vec::new(), + Vec::new(), + None, + true, + Vec::new(), + )); + } else { + pending.extend(child.sub_issues); + } + } + let merge_results = stream::iter(leaf_children) + .map(|(child, repository)| async move { + self.direct_merge_evidence(&child, &repository).await + }) + .buffer_unordered(PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY) + .collect::>() + .await; + for result in merge_results { + let ( + confirmed, + commit, + child_repository_id, + _child_repository_ids, + child_commits, + child_evidence_at, + child_evidence_by_issue, + ) = result?; + if !confirmed { + return Ok(( + false, + None, + None, + Vec::new(), + Vec::new(), + None, + true, + Vec::new(), + )); + } + if let Some(repository_id) = child_repository_id { + repository_ids.insert(repository_id); + } + commits.extend(child_commits); + provider_evidence_by_issue.extend(child_evidence_by_issue); + provider_evidence_at = match (provider_evidence_at, child_evidence_at) { + (Some(current), Some(candidate)) => Some(current.min(candidate)), + (None, candidate) => candidate, + (current, None) => current, + }; + if commit.is_none() { + return Ok(( + false, + None, + None, + Vec::new(), + Vec::new(), + None, + true, + Vec::new(), + )); + } + } + } + let commit = commits.first().map(|commit| commit.commit.clone()); + let repository_id = (repository_ids.len() == 1) + .then(|| repository_ids.iter().next().cloned()) + .flatten(); + Ok(( + !saw_leaf || !commits.is_empty(), + commit, + repository_id, + repository_ids.into_iter().collect(), + commits, + provider_evidence_at, + saw_leaf, + provider_evidence_by_issue, + )) + } +} + +fn github_url_authority(url: &Url) -> Option { + let host = url.host_str()?.to_ascii_lowercase(); + let authority = url + .port() + .map_or(host.clone(), |port| format!("{host}:{port}")); + Some(normalize_github_authority(&authority)) +} + +fn github_remote_authority(locator: &str) -> Option { + let locator = locator.trim(); + if let Ok(url) = Url::parse(locator) { + let authority = github_url_authority(&url)?; + if matches!(url.scheme(), "ssh" | "git+ssh") && url.port() == Some(22) { + return Some(normalize_github_authority(url.host_str()?)); + } + return Some(authority); + } + let scp_authority = locator + .strip_prefix("git@") + .or_else(|| locator.strip_prefix("ssh@")) + .and_then(|locator| locator.split_once(':').map(|(authority, _)| authority)); + if let Some(authority) = scp_authority { + return Some(normalize_github_authority(&authority.to_ascii_lowercase())); + } + if locator.split('/').count() == 2 { + return Some("github.com".to_owned()); + } + if let [authority, _owner, _repository] = locator.split('/').collect::>().as_slice() { + return Some(normalize_github_authority(authority)); + } + None +} + +fn github_remote_repository(locator: &str) -> Option<(String, String)> { + let locator = locator.trim(); + let path = if let Ok(url) = Url::parse(locator) { + url.path().to_owned() + } else if let Some((_, path)) = locator + .strip_prefix("git@") + .and_then(|value| value.split_once(':')) + { + path.to_owned() + } else if let Some((_, path)) = locator + .strip_prefix("ssh@") + .and_then(|value| value.split_once(':')) + { + path.to_owned() + } else { + locator.to_owned() + }; + let mut segments = path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| segment.trim_end_matches(".git")) + .collect::>(); + if segments.len() < 2 { + return None; + } + let repository = segments.pop()?.to_owned(); + let owner = segments.pop()?.to_owned(); + Some((owner, repository)) +} + +fn normalize_github_authority(authority: &str) -> String { + let authority = authority.trim().to_ascii_lowercase(); + match authority.strip_prefix("www.") { + Some("github.com") => "github.com".to_owned(), + Some(_) | None => authority, + } +} + +fn github_required_status_checks_endpoint( + api_root: &str, + owner: &str, + repository_name: &str, + target_branch: &str, +) -> Result { + let mut endpoint = Url::parse(api_root).map_err(|error| { + LinearError::InvalidResponse(format!("invalid GitHub API root: {error}")) + })?; + { + let mut segments = endpoint.path_segments_mut().map_err(|_| { + LinearError::InvalidResponse("GitHub API root cannot be a base URL".to_owned()) + })?; + segments + .push("repos") + .push(owner) + .push(repository_name) + .push("branches") + .push(target_branch) + .push("protection") + .push("required_status_checks"); + } + Ok(endpoint.to_string()) +} + +fn github_merge_method_matches(expected_method: &str, parent_count: usize) -> bool { + match expected_method.trim().to_ascii_lowercase().as_str() { + "merge" => parent_count > 1, + _ => false, + } +} + +fn github_timestamp_ms(value: &str) -> Option { + let millis = chrono::DateTime::parse_from_rfc3339(value) + .ok()? + .timestamp_millis(); + (millis >= 0).then_some(TimestampMs::new(millis as u64)) +} + +fn github_provider_evidence_timestamp_ms(value: &str) -> Option { + // GitHub's merge and creation timestamps are normally second-precision. + // Keep that evidence at the beginning of its precision window: a run + // starting later in the same second is ambiguous and must remain fenced + // instead of being allowed to reuse a prior merge. + github_timestamp_ms(value) +} + +fn github_headers_indicate_rate_limit(headers: &reqwest::header::HeaderMap) -> bool { + headers + .get("x-ratelimit-remaining") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .is_some_and(|remaining| remaining == 0) +} + +fn github_retry_after(headers: &reqwest::header::HeaderMap) -> Option { + let retry_after = headers + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .map(Duration::from_secs); + retry_after.or_else(|| { + let reset_at = headers + .get("x-ratelimit-reset") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok())?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + Some(Duration::from_secs(reset_at.saturating_sub(now))) + }) +} + +fn github_compare_contains_commit(comparison: &GitHubCompare) -> bool { + comparison.ahead_by == 0 + && matches!( + comparison.status.to_ascii_lowercase().as_str(), + "behind" | "identical" + ) +} + +fn required_check_evidence_satisfied( + check_runs: &[GitHubCheckRun], + commit_statuses: &[GitHubCommitStatus], + required_checks: Option<&GitHubRequiredStatusChecks>, +) -> bool { + match required_checks { + Some(required_checks) => { + let latest_statuses = latest_commit_statuses(commit_statuses); + let legacy_contexts_satisfied = required_checks.contexts.iter().all(|context| { + latest_check_run(check_runs, |check| check.name.as_deref() == Some(context)) + .is_some_and(|check| { + check.status.eq_ignore_ascii_case("completed") + && check + .conclusion + .as_deref() + .is_some_and(is_passing_check_conclusion) + }) + || latest_statuses + .get(context) + .is_some_and(|status| status.state.eq_ignore_ascii_case("success")) + }); + let app_bound_checks_satisfied = required_checks.checks.iter().all(|required| { + latest_check_run(check_runs, |check| { + check.name.as_deref() == Some(required.context.as_str()) + && required_check_run_app_matches(check, required.app_id) + }) + .is_some_and(|check| { + check.status.eq_ignore_ascii_case("completed") + && check + .conclusion + .as_deref() + .is_some_and(is_passing_check_conclusion) + }) + }); + legacy_contexts_satisfied && app_bound_checks_satisfied + } + None => check_runs.iter().any(|check| { + check.status.eq_ignore_ascii_case("completed") + && check + .conclusion + .as_deref() + .is_some_and(is_passing_check_conclusion) + }), + } +} + +fn required_check_run_app_matches(check: &GitHubCheckRun, app_id: Option) -> bool { + match app_id { + Some(app_id) if app_id >= 0 => check.app.as_ref().is_some_and(|app| { + i64::try_from(app.id).is_ok_and(|check_app_id| check_app_id == app_id) + }), + // GitHub represents an any-App required check with the signed sentinel + // -1. Do not constrain the check run's App identity in that case. + Some(-1) | None => true, + Some(_) => false, + } +} + +fn is_passing_check_conclusion(conclusion: &str) -> bool { + matches!( + conclusion.to_ascii_lowercase().as_str(), + "success" | "neutral" | "skipped" + ) +} + +fn latest_check_run(check_runs: &[GitHubCheckRun], mut matches: F) -> Option<&GitHubCheckRun> +where + F: FnMut(&GitHubCheckRun) -> bool, +{ + check_runs + .iter() + .filter(|check| matches(check)) + .max_by_key(|check| { + ( + check + .created_at + .as_deref() + .or(check.started_at.as_deref()) + .or(check.completed_at.as_deref()) + .and_then(github_timestamp_ms) + .map(TimestampMs::as_u64), + check.id, + ) + }) +} + +fn latest_commit_statuses<'a>( + commit_statuses: &'a [GitHubCommitStatus], +) -> BTreeMap { + let mut latest: BTreeMap = BTreeMap::new(); + for status in commit_statuses { + let timestamp = status + .updated_at + .as_deref() + .or(status.created_at.as_deref()) + .unwrap_or_default(); + let replace = latest.get(status.context.as_str()).is_none_or(|current| { + let current_timestamp = current + .updated_at + .as_deref() + .or(current.created_at.as_deref()) + .unwrap_or_default(); + (timestamp, status.id) >= (current_timestamp, current.id) + }); + if replace { + latest.insert(status.context.clone(), status); + } + } + latest +} + +/// Keep historical attachment evidence bounded while prioritizing the +/// provider's current/singular PR projection. Linear can retain every PR ever +/// attached to an issue; allowing that list to fan out without a total bound +/// turns one parent eligibility check into unbounded provider work. +fn parent_pull_request_candidates(issue: &TrackerIssue) -> Vec { + let mut candidates = Vec::with_capacity(issue.pr_urls.len() + 1); + if let Some(pr_url) = issue.pr_url.as_ref() { + candidates.push(pr_url.clone()); + } + candidates.extend(issue.pr_urls.iter().cloned()); + let mut seen = HashSet::new(); + candidates + .into_iter() + .filter(|url| seen.insert(url.clone())) + .take(MAX_PARENT_PULL_REQUEST_EVIDENCE_CANDIDATES) + .collect() +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubPullRequest { + created_at: String, + merged_at: Option, + merge_commit_sha: Option, + base: GitHubPullRequestBase, + head: GitHubPullRequestHead, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubCommit { + #[serde(default)] + parents: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubCompare { + status: String, + #[serde(default)] + ahead_by: u64, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubCommitParent { + #[allow(dead_code)] + sha: String, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubPullRequestReview { + #[serde(default)] + id: u64, + state: String, + #[serde(default)] + submitted_at: Option, + #[serde(default)] + user: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct GitHubReviewUser { + #[serde(default)] + login: Option, +} + +fn latest_github_review_states( + reviews: impl IntoIterator, +) -> BTreeMap { + let mut latest_by_reviewer: BTreeMap = BTreeMap::new(); + for review in reviews { + let reviewer = review + .user + .and_then(|user| user.login) + .unwrap_or_else(|| format!("review-{}", latest_by_reviewer.len())); + let submitted_at = review.submitted_at.unwrap_or_default(); + if !matches!( + review.state.to_ascii_lowercase().as_str(), + "approved" | "changes_requested" | "dismissed" + ) { + continue; + } + if latest_by_reviewer + .get(&reviewer) + .is_none_or(|(_, timestamp, review_id)| { + timestamp.as_str() < submitted_at.as_str() + || (timestamp == &submitted_at && *review_id <= review.id) + }) + { + latest_by_reviewer.insert(reviewer, (review.state, submitted_at, review.id)); + } + } + latest_by_reviewer +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubCheckRuns { + #[serde(default)] + total_count: usize, + #[serde(default)] + check_runs: Vec, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct GitHubCheckRun { + #[serde(default)] + id: u64, + #[serde(default)] + name: Option, + status: String, + #[serde(default)] + conclusion: Option, + #[serde(default)] + app: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + started_at: Option, + #[serde(default)] + completed_at: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubCheckRunApp { + id: u64, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubRequiredStatusChecks { + #[serde(default)] + contexts: Vec, + #[serde(default)] + checks: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubRequiredStatusCheck { + context: String, + #[serde(default)] + app_id: Option, } -impl TrackerBackend for RuntimeTrackerBackend { - type Error = LinearError; +#[derive(Debug, serde::Deserialize)] +struct GitHubCommitStatuses { + #[serde(default)] + total_count: usize, + #[serde(default)] + statuses: Vec, +} - async fn candidate_issues(&mut self) -> Result, Self::Error> { - self.client.candidate_issues().await - } +#[derive(Debug, serde::Deserialize)] +struct GitHubCommitStatus { + #[serde(default)] + id: u64, + context: String, + state: String, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, +} - async fn candidate_issue_summaries(&mut self) -> Result, Self::Error> { - self.client.candidate_issue_summaries().await - } +#[derive(Debug, serde::Deserialize)] +struct GitHubPullRequestBase { + #[serde(rename = "ref")] + ref_name: String, + repo: GitHubRepository, +} - async fn terminal_issues(&mut self) -> Result, Self::Error> { - self.client.terminal_issues().await - } +#[derive(Debug, serde::Deserialize)] +struct GitHubPullRequestHead { + #[serde(rename = "ref")] + ref_name: String, + #[serde(default)] + sha: Option, +} - async fn issues_by_identifiers( - &mut self, - identifiers: &[String], - ) -> Result, Self::Error> { - self.client.issues_by_identifiers(identifiers).await +#[derive(Debug)] +struct GithubMergeEvidence { + compatible: bool, + merged: bool, + merge_commit_sha: Option, + merge_repository_id: Option, + created_at: String, + pull_number: u64, + provider_evidence_at: Option, +} + +impl GithubMergeEvidence { + fn incompatible() -> Self { + Self { + compatible: false, + merged: false, + merge_commit_sha: None, + merge_repository_id: None, + created_at: String::new(), + pull_number: 0, + provider_evidence_at: None, + } } +} - async fn issue_states_by_ids( - &mut self, - issue_ids: &[String], - ) -> Result, Self::Error> { - self.client.issue_states_by_ids(issue_ids).await - } +fn select_current_github_merge_evidence( + candidates: Vec, +) -> ( + bool, + Option, + Option, + Option, +) { + candidates + .into_iter() + .filter(|candidate| candidate.compatible) + .max_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.pull_number.cmp(&right.pull_number)) + }) + .map_or((false, None, None, None), |candidate| { + ( + candidate.merged, + candidate.merge_commit_sha, + candidate.merge_repository_id, + candidate.provider_evidence_at, + ) + }) +} - fn error_category(error: &Self::Error) -> Option { - Some(error.category()) - } +#[derive(Debug, serde::Deserialize)] +struct GitHubRepository { + #[serde(default)] + id: Option, + #[serde(default)] + node_id: Option, + #[serde(default)] + full_name: String, +} - fn retry_after(error: &Self::Error) -> Option { - error.retry_after() +impl GitHubRepository { + fn native_ids(&self) -> Vec { + self.node_id + .iter() + .cloned() + .chain(self.id.iter().map(ToString::to_string)) + .collect() } } @@ -962,6 +2400,7 @@ impl RuntimeWorkspaceBackend { .map(|state| normalized_state_name(state)) .collect(), terminal_cleanup_paths: HashSet::new(), + recovered_run_started_at: BTreeMap::new(), codex_bin: env::var("OPENSYMPHONY_CODEX_BIN").unwrap_or_else(|_| "codex".into()), retain_failed, retry_state_root, @@ -993,6 +2432,13 @@ impl RuntimeWorkspaceBackend { force_remove: bool, ) -> Result<(), CliWorkspaceError> { if terminal && (force_remove || !self.terminal_cleanup_paths.contains(&workspace.path)) { + if self.workspace_has_active_lease(workspace).await? { + tracing::debug!( + issue = %workspace.workspace_key, + "retaining terminal workspace while a durable lease is active" + ); + return Err(CliWorkspaceError::CleanupDeferred); + } let Some(handle) = self .manager .list_all_workspaces() @@ -1227,8 +2673,15 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { async fn recover_workspaces(&mut self) -> Result, Self::Error> { let mut recoveries = Vec::new(); + self.recovered_run_started_at.clear(); for (handle, manifest) in self.manager.list_all_workspaces().await? { let mut run_manifest = self.manager.load_run_manifest(&handle).await?; + if let Some(run) = run_manifest.as_ref() { + self.recovered_run_started_at.insert( + IssueId::new(run.issue_id.clone())?, + datetime_to_timestamp_ms(run.started_at.unwrap_or(run.created_at)), + ); + } let had_in_flight_run = run_manifest.as_ref().is_some_and(|run| { matches!( run.status, @@ -1315,6 +2768,105 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { Ok(recoveries) } + async fn recovered_run_started_at( + &mut self, + ) -> Result, Self::Error> { + Ok(self.recovered_run_started_at.clone()) + } + + async fn load_orchestrator_state(&mut self) -> Result, Self::Error> { + self.manager + .load_orchestrator_state() + .await + .map_err(CliWorkspaceError::Workspace) + } + + async fn persist_orchestrator_state( + &mut self, + state: &serde_json::Value, + ) -> Result<(), Self::Error> { + self.manager + .write_orchestrator_state_atomically(state) + .await + .map_err(CliWorkspaceError::Workspace) + } + + async fn workspace_lease_resource( + &mut self, + issue: &NormalizedIssue, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + ) -> Result, Self::Error> { + let Some((handle, manifest)) = self + .manager + .list_all_workspaces() + .await? + .into_iter() + .find(|(handle, _)| handle.workspace_path() == workspace.path) + else { + return Ok(None); + }; + let Some(repository_id) = manifest + .repository_binding + .as_ref() + .and_then(RepositoryBindingOutcome::repository_id) + .cloned() + else { + return Ok(None); + }; + let Some(checkout_generation) = handle.checkout_generation() else { + return Ok(None); + }; + Ok(Some(LeaseResource { + issue_id: issue.id.clone(), + repository_id, + checkout_generation: checkout_generation.to_owned(), + })) + } + + async fn workspace_has_active_lease( + &mut self, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + ) -> Result { + let Some(raw) = self + .manager + .load_orchestrator_state::() + .await? + else { + return Ok(false); + }; + let state: DurableOrchestratorState = serde_json::from_value(raw).map_err(|error| { + CliWorkspaceError::RetryState(format!("invalid durable hierarchy state: {error}")) + })?; + state.validate().map_err(CliWorkspaceError::RetryState)?; + let Some((handle, manifest)) = self + .manager + .list_all_workspaces() + .await? + .into_iter() + .find(|(handle, _)| handle.workspace_path() == workspace.path) + else { + return Ok(false); + }; + let Some(generation) = handle.checkout_generation() else { + return Ok(false); + }; + let resource = LeaseResource { + issue_id: IssueId::new(manifest.issue_id.clone())?, + repository_id: manifest + .repository_binding + .as_ref() + .and_then(RepositoryBindingOutcome::repository_id) + .cloned() + .ok_or_else(|| { + CliWorkspaceError::RetryState( + "managed checkout is missing its canonical repository identity".to_owned(), + ) + })?, + checkout_generation: generation.to_owned(), + }; + Ok(state.active_for(&resource)) + } + async fn recover_retry_exhaustion( &mut self, ) -> Result, Self::Error> { @@ -2716,9 +4268,10 @@ impl RuntimeWorkerBackend { if route.dry_run { if let Some(sender) = launch_tx.take() { - let _ = sender.send(LaunchReport::Conversation(Box::new( - dry_run_conversation_metadata(&run, &route), - ))); + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(dry_run_conversation_metadata(&run, &route)), + started_at: run_manifest.started_at.map(datetime_to_timestamp_ms), + }); } let finish_error = finish_route_dry_run_workspace_run( &workspace_manager, @@ -2918,9 +4471,15 @@ impl RuntimeWorkerBackend { >, ) -> Result { match result { - Ok(Ok(LaunchReport::Conversation(conversation))) => { + Ok(Ok(LaunchReport::Conversation { + conversation, + started_at, + })) => { let conversation = annotate_route_decision(*conversation, worker_id, route); - Ok(WorkerLaunch { conversation }) + Ok(WorkerLaunch { + conversation, + started_at, + }) } Ok(Ok(LaunchReport::Failed(detail))) => { if let Some(task) = self.take_tracked_task(worker_id) { @@ -3491,6 +5050,7 @@ async fn try_run_codex_stdio_issue( }) { run_manifest.status = RunStatus::Running; + run_manifest.started_at.get_or_insert_with(chrono::Utc::now); run_manifest.status_detail = Some("reattaching to an active Codex turn".to_owned()); run_manifest.updated_at = chrono::Utc::now(); workspace_manager @@ -3851,9 +5411,10 @@ async fn try_run_codex_stdio_issue( }, )?; if let Some(sender) = launch_tx.take() { - let _ = sender.send(LaunchReport::Conversation(Box::new( - codex_conversation_metadata(conversation_id.clone(), route), - ))); + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(codex_conversation_metadata(conversation_id.clone(), route)), + started_at: run_manifest.started_at.map(datetime_to_timestamp_ms), + }); if let Some(grants) = fresh_conversation_grants.as_ref() { grants.acknowledge_fresh_conversation(fresh_conversation_issue); } @@ -4016,9 +5577,10 @@ async fn try_run_codex_stdio_issue( }, )?; if let Some(sender) = launch_tx.take() { - let _ = sender.send(LaunchReport::Conversation(Box::new( - codex_conversation_metadata(conversation_id.clone(), route), - ))); + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(codex_conversation_metadata(conversation_id.clone(), route)), + started_at: run_manifest.started_at.map(datetime_to_timestamp_ms), + }); if let Some(grants) = fresh_conversation_grants.as_ref() { grants.acknowledge_fresh_conversation(fresh_conversation_issue); } @@ -5449,6 +7011,7 @@ async fn persist_codex_run_started( prompt_kind: IssueSessionPromptKind, ) -> Result<(), String> { run_manifest.status = RunStatus::Running; + run_manifest.started_at.get_or_insert_with(chrono::Utc::now); run_manifest.status_detail = Some(format!( "{} prompt sent to Codex conversation {conversation_id}", prompt_kind.as_str() @@ -5843,6 +7406,7 @@ fn normalized_issue_from_manifest( }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: Vec::new(), project_id: None, @@ -6233,6 +7797,7 @@ mod tests { interrupt_reason: None, status: RunStatus::Prepared, created_at: now, + started_at: None, updated_at: now, status_detail: None, hooks: Vec::new(), @@ -6306,6 +7871,7 @@ mod tests { interrupt_reason: None, status: RunStatus::Prepared, created_at: now, + started_at: None, updated_at: now, status_detail: None, hooks: Vec::new(), @@ -6387,6 +7953,7 @@ mod tests { interrupt_reason: None, status: RunStatus::Prepared, created_at: now, + started_at: None, updated_at: now, status_detail: None, hooks: Vec::new(), @@ -6861,7 +8428,7 @@ mod tests { .await .expect("launch report should be sent before terminal completion"); match launch { - LaunchReport::Conversation(conversation) => { + LaunchReport::Conversation { conversation, .. } => { assert_eq!(conversation.conversation_id.as_str(), "fake-thread"); assert_eq!(conversation.stream_state, RuntimeStreamState::Closed); } @@ -7774,7 +9341,7 @@ mod tests { assert_eq!(outcome.outcome, WorkerOutcomeKind::Succeeded); assert!(matches!( launch_rx.await.expect("launch report should be sent"), - LaunchReport::Conversation(_) + LaunchReport::Conversation { .. } )); let log = fs::read_to_string(&log_path).expect("fake child log should exist"); assert!( @@ -7871,7 +9438,7 @@ mod tests { assert_eq!(run_manifest.status, RunStatus::Succeeded); assert!(matches!( launch_rx.await.expect("launch report should be sent"), - LaunchReport::Conversation(conversation) + LaunchReport::Conversation { conversation, .. } if conversation.conversation_id.as_str() == "fake-thread" )); let log = fs::read_to_string(&log_path).expect("fake child log should exist"); @@ -7967,7 +9534,7 @@ mod tests { assert_eq!(run_manifest.status, RunStatus::Succeeded); assert!(matches!( launch_rx.await.expect("launch report should be sent"), - LaunchReport::Conversation(conversation) + LaunchReport::Conversation { conversation, .. } if conversation.conversation_id.as_str() == "fake-thread" )); let log = fs::read_to_string(&log_path).expect("fake child log should exist"); @@ -7996,6 +9563,7 @@ mod tests { .await .expect("run should start"); run_manifest.status = RunStatus::Running; + run_manifest.started_at.get_or_insert_with(chrono::Utc::now); workspace_manager .write_run_manifest(&ensured.handle, &run_manifest) .await @@ -8067,7 +9635,7 @@ mod tests { assert_eq!(run_manifest.status, RunStatus::Cancelled); assert!(matches!( launch_rx.await.expect("launch report should be sent"), - LaunchReport::Conversation(conversation) + LaunchReport::Conversation { conversation, .. } if conversation.conversation_id.as_str() == "fake-thread" )); let log = fs::read_to_string(&log_path).expect("fake child log should exist"); @@ -8145,7 +9713,7 @@ mod tests { .expect("launch sender should stay alive"); assert!(matches!( launch, - LaunchReport::Conversation(conversation) + LaunchReport::Conversation { conversation, .. } if conversation.conversation_id.as_str() == "fake-thread" )); @@ -8267,7 +9835,7 @@ mod tests { .expect("launch sender should stay alive"); assert!(matches!( launch, - LaunchReport::Conversation(conversation) + LaunchReport::Conversation { conversation, .. } if conversation.conversation_id.as_str() == "fake-thread" )); @@ -8364,7 +9932,7 @@ mod tests { .expect("launch report should be sent before terminal completion"); assert!(matches!( launch, - LaunchReport::Conversation(conversation) + LaunchReport::Conversation { conversation, .. } if conversation.conversation_id.as_str() == "fake-thread" )); assert!( @@ -8446,7 +10014,7 @@ mod tests { let launch = launch_rx.await.expect("launch report should still be sent"); assert!(matches!( launch, - LaunchReport::Conversation(conversation) + LaunchReport::Conversation { conversation, .. } if conversation.conversation_id.as_str() == "fake-thread" )); assert!( @@ -8846,6 +10414,7 @@ mod tests { .await .expect("initial run should be persisted"); run_manifest.status = RunStatus::Running; + run_manifest.started_at.get_or_insert_with(chrono::Utc::now); workspace_manager .write_run_manifest(&ensured.handle, &run_manifest) .await @@ -8971,7 +10540,27 @@ mod tests { assert_eq!( env_remove, - BTreeSet::from(["NODE_ENV".to_owned(), "CHECKOUT_TOKEN".to_owned()]) + BTreeSet::from([ + "GITHUB_TOKEN".to_owned(), + "NODE_ENV".to_owned(), + "CHECKOUT_TOKEN".to_owned(), + ]) + ); + } + + #[test] + fn github_remote_repository_matches_supported_locator_shapes() { + assert_eq!( + github_remote_repository("https://github.com/owner/repo.git"), + Some(("owner".to_owned(), "repo".to_owned())) + ); + assert_eq!( + github_remote_repository("git@github.enterprise.example:owner/repo"), + Some(("owner".to_owned(), "repo".to_owned())) + ); + assert_eq!( + github_remote_repository("github.enterprise.example/owner/repo"), + Some(("owner".to_owned(), "repo".to_owned())) ); } @@ -9163,6 +10752,7 @@ mod tests { .expect("run manifest should load") .expect("run manifest should exist"); run_manifest.status = RunStatus::Running; + run_manifest.started_at.get_or_insert_with(chrono::Utc::now); workspace_manager .write_run_manifest(&ensured.handle, &run_manifest) .await @@ -10011,6 +11601,460 @@ Run the scheduler. ); } + #[test] + fn current_merge_evidence_rejects_stale_merged_pr_when_replacement_is_newer() { + let selected = select_current_github_merge_evidence(vec![ + GithubMergeEvidence { + compatible: true, + merged: true, + merge_commit_sha: Some("old-merge".to_owned()), + merge_repository_id: None, + created_at: "2026-08-12T00:00:00Z".to_owned(), + pull_number: 12, + provider_evidence_at: None, + }, + GithubMergeEvidence { + compatible: true, + merged: false, + merge_commit_sha: None, + merge_repository_id: None, + created_at: "2026-08-13T00:00:00Z".to_owned(), + pull_number: 34, + provider_evidence_at: None, + }, + ]); + assert_eq!(selected, (false, None, None, None)); + + let selected = select_current_github_merge_evidence(vec![ + GithubMergeEvidence { + compatible: true, + merged: true, + merge_commit_sha: Some("old-merge".to_owned()), + merge_repository_id: None, + created_at: "2026-08-12T00:00:00Z".to_owned(), + pull_number: 12, + provider_evidence_at: None, + }, + GithubMergeEvidence { + compatible: true, + merged: true, + merge_commit_sha: Some("current-merge".to_owned()), + merge_repository_id: None, + created_at: "2026-08-13T00:00:00Z".to_owned(), + pull_number: 34, + provider_evidence_at: None, + }, + ]); + assert_eq!( + selected, + (true, Some("current-merge".to_owned()), None, None) + ); + + let selected = select_current_github_merge_evidence(vec![ + GithubMergeEvidence { + compatible: true, + merged: true, + merge_commit_sha: Some("lower-number-merge".to_owned()), + merge_repository_id: None, + created_at: "2026-08-13T00:00:00Z".to_owned(), + pull_number: 12, + provider_evidence_at: None, + }, + GithubMergeEvidence { + compatible: true, + merged: false, + merge_commit_sha: None, + merge_repository_id: None, + created_at: "2026-08-13T00:00:00Z".to_owned(), + pull_number: 34, + provider_evidence_at: None, + }, + ]); + assert_eq!(selected, (false, None, None, None)); + } + + #[test] + fn parent_pull_request_candidates_prioritize_current_and_bound_history() { + let mut issue = sample_tracker_issue(&sample_issue()); + issue.state = "Done".to_owned(); + issue.pr_url = Some("https://github.com/kumanday/OpenSymphony/pull/999".to_owned()); + issue.pr_urls = (1..=MAX_PARENT_PULL_REQUEST_EVIDENCE_CANDIDATES + 10) + .map(|number| format!("https://github.com/kumanday/OpenSymphony/pull/{number}")) + .collect(); + + let candidates = parent_pull_request_candidates(&issue); + assert_eq!( + candidates.len(), + MAX_PARENT_PULL_REQUEST_EVIDENCE_CANDIDATES + ); + assert_eq!( + candidates.first().map(String::as_str), + issue.pr_url.as_deref() + ); + let current_pr = issue.pr_url.clone().expect("current PR should be present"); + assert_eq!( + candidates.iter().filter(|url| *url == ¤t_pr).count(), + 1 + ); + } + + #[test] + fn latest_reviews_break_equal_timestamp_ties_with_review_id() { + let reviewer = Some(GitHubReviewUser { + login: Some("reviewer".to_owned()), + }); + let latest = latest_github_review_states(vec![ + GitHubPullRequestReview { + id: 22, + state: "changes_requested".to_owned(), + submitted_at: Some("2026-08-14T15:00:00Z".to_owned()), + user: reviewer.clone(), + }, + GitHubPullRequestReview { + id: 21, + state: "approved".to_owned(), + submitted_at: Some("2026-08-14T15:00:00Z".to_owned()), + user: reviewer, + }, + ]); + + assert_eq!( + latest.get("reviewer"), + Some(&( + "changes_requested".to_owned(), + "2026-08-14T15:00:00Z".to_owned(), + 22, + )) + ); + } + + #[test] + fn provider_evidence_timestamp_respects_github_precision() { + let second_precision = github_timestamp_ms("1970-01-01T00:00:01Z").expect("timestamp"); + assert_eq!( + github_provider_evidence_timestamp_ms("1970-01-01T00:00:01Z") + .expect("timestamp") + .as_u64(), + second_precision.as_u64() + ); + assert_eq!( + github_provider_evidence_timestamp_ms("1970-01-01T00:00:01.123Z") + .expect("timestamp") + .as_u64(), + 1123 + ); + } + + #[test] + fn github_rate_limit_headers_preserve_retry_metadata() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "x-ratelimit-remaining", + reqwest::header::HeaderValue::from_static("0"), + ); + headers.insert( + "retry-after", + reqwest::header::HeaderValue::from_static("7"), + ); + + assert!(github_headers_indicate_rate_limit(&headers)); + assert_eq!(github_retry_after(&headers), Some(Duration::from_secs(7))); + } + + #[test] + fn github_merge_evidence_matches_configured_merge_method() { + assert!(github_merge_method_matches("merge", 2)); + assert!(!github_merge_method_matches("merge", 1)); + assert!(!github_merge_method_matches("squash", 1)); + assert!(!github_merge_method_matches("rebase", 1)); + } + + #[test] + fn github_compare_rejects_force_pushed_or_diverged_target() { + assert!(github_compare_contains_commit(&GitHubCompare { + status: "behind".to_owned(), + ahead_by: 0, + })); + assert!(github_compare_contains_commit(&GitHubCompare { + status: "identical".to_owned(), + ahead_by: 0, + })); + assert!(!github_compare_contains_commit(&GitHubCompare { + status: "ahead".to_owned(), + ahead_by: 1, + })); + assert!(!github_compare_contains_commit(&GitHubCompare { + status: "diverged".to_owned(), + ahead_by: 0, + })); + } + + #[test] + fn github_required_status_checks_endpoint_encodes_branch_segments() { + let endpoint = github_required_status_checks_endpoint( + "https://api.github.com", + "owner", + "repository", + "release/next", + ) + .expect("GitHub endpoint should build"); + + assert_eq!( + endpoint, + "https://api.github.com/repos/owner/repository/branches/release%2Fnext/protection/required_status_checks" + ); + } + + #[test] + fn required_check_evidence_ignores_optional_failed_runs() { + let checks = vec![ + GitHubCheckRun { + name: Some("required".to_owned()), + status: "completed".to_owned(), + conclusion: Some("success".to_owned()), + app: None, + ..Default::default() + }, + GitHubCheckRun { + name: Some("optional".to_owned()), + status: "completed".to_owned(), + conclusion: Some("failure".to_owned()), + app: None, + ..Default::default() + }, + ]; + let required = GitHubRequiredStatusChecks { + contexts: vec!["required".to_owned()], + checks: Vec::new(), + }; + assert!(required_check_evidence_satisfied( + &checks, + &[], + Some(&required) + )); + assert!(required_check_evidence_satisfied(&checks, &[], None)); + + let missing = GitHubRequiredStatusChecks { + contexts: vec!["missing".to_owned()], + checks: Vec::new(), + }; + assert!(!required_check_evidence_satisfied( + &checks, + &[], + Some(&missing) + )); + let all_required = GitHubRequiredStatusChecks { + contexts: vec!["required".to_owned(), "lint".to_owned()], + checks: Vec::new(), + }; + assert!(!required_check_evidence_satisfied( + &checks, + &[], + Some(&all_required) + )); + assert!(required_check_evidence_satisfied( + &checks, + &[GitHubCommitStatus { + id: 1, + context: "lint".to_owned(), + state: "success".to_owned(), + created_at: None, + updated_at: None, + }], + Some(&all_required), + )); + } + + #[test] + fn required_check_evidence_uses_the_latest_matching_check_run() { + let checks = vec![ + GitHubCheckRun { + id: 10, + name: Some("required".to_owned()), + status: "completed".to_owned(), + conclusion: Some("success".to_owned()), + created_at: Some("2026-08-13T07:00:00Z".to_owned()), + ..Default::default() + }, + GitHubCheckRun { + id: 11, + name: Some("required".to_owned()), + status: "completed".to_owned(), + conclusion: Some("failure".to_owned()), + created_at: Some("2026-08-13T07:01:00Z".to_owned()), + ..Default::default() + }, + ]; + let required = GitHubRequiredStatusChecks { + contexts: vec!["required".to_owned()], + checks: Vec::new(), + }; + + assert!(!required_check_evidence_satisfied( + &checks, + &[], + Some(&required), + )); + } + + #[test] + fn required_check_evidence_accepts_neutral_and_skipped_runs() { + for conclusion in ["neutral", "skipped"] { + let checks = vec![GitHubCheckRun { + name: Some("required".to_owned()), + status: "completed".to_owned(), + conclusion: Some(conclusion.to_owned()), + app: None, + ..Default::default() + }]; + let required = GitHubRequiredStatusChecks { + contexts: vec!["required".to_owned()], + checks: Vec::new(), + }; + + assert!( + required_check_evidence_satisfied(&checks, &[], Some(&required)), + "{conclusion} should satisfy a completed required check" + ); + } + } + + #[test] + fn app_bound_required_checks_reject_other_apps_and_classic_statuses() { + let required = GitHubRequiredStatusChecks { + contexts: Vec::new(), + checks: vec![GitHubRequiredStatusCheck { + context: "protected".to_owned(), + app_id: Some(42), + }], + }; + let successful_other_app = vec![GitHubCheckRun { + name: Some("protected".to_owned()), + status: "completed".to_owned(), + conclusion: Some("success".to_owned()), + app: Some(GitHubCheckRunApp { id: 7 }), + ..Default::default() + }]; + assert!(!required_check_evidence_satisfied( + &successful_other_app, + &[], + Some(&required), + )); + assert!(!required_check_evidence_satisfied( + &[], + &[GitHubCommitStatus { + id: 1, + context: "protected".to_owned(), + state: "success".to_owned(), + created_at: None, + updated_at: None, + }], + Some(&required), + )); + + let successful_required_app = vec![GitHubCheckRun { + name: Some("protected".to_owned()), + status: "completed".to_owned(), + conclusion: Some("success".to_owned()), + app: Some(GitHubCheckRunApp { id: 42 }), + ..Default::default() + }]; + assert!(required_check_evidence_satisfied( + &successful_required_app, + &[], + Some(&required), + )); + + let older_required_app = GitHubCheckRun { + name: Some("protected".to_owned()), + status: "completed".to_owned(), + conclusion: Some("success".to_owned()), + app: Some(GitHubCheckRunApp { id: 42 }), + created_at: Some("2026-08-13T06:00:00Z".to_owned()), + ..Default::default() + }; + let newer_other_app = GitHubCheckRun { + name: Some("protected".to_owned()), + status: "completed".to_owned(), + conclusion: Some("success".to_owned()), + app: Some(GitHubCheckRunApp { id: 7 }), + created_at: Some("2026-08-13T07:00:00Z".to_owned()), + ..Default::default() + }; + assert!(required_check_evidence_satisfied( + &[older_required_app, newer_other_app], + &[], + Some(&required), + )); + + let any_app = GitHubRequiredStatusChecks { + contexts: Vec::new(), + checks: vec![GitHubRequiredStatusCheck { + context: "protected".to_owned(), + app_id: Some(-1), + }], + }; + assert!(required_check_evidence_satisfied( + &successful_other_app, + &[], + Some(&any_app), + )); + } + + #[test] + fn required_status_context_uses_the_newest_commit_status() { + let required = GitHubRequiredStatusChecks { + contexts: vec!["lint".to_owned()], + checks: Vec::new(), + }; + let statuses = vec![ + GitHubCommitStatus { + id: 1, + context: "lint".to_owned(), + state: "success".to_owned(), + created_at: Some("2026-08-13T07:00:00Z".to_owned()), + updated_at: Some("2026-08-13T07:00:00Z".to_owned()), + }, + GitHubCommitStatus { + id: 2, + context: "lint".to_owned(), + state: "failure".to_owned(), + created_at: Some("2026-08-13T07:00:00Z".to_owned()), + updated_at: Some("2026-08-13T07:00:00Z".to_owned()), + }, + ]; + + assert!(!required_check_evidence_satisfied( + &[], + &statuses, + Some(&required), + )); + } + + #[test] + fn github_remote_authority_accepts_schemeless_enterprise_locator() { + assert_eq!( + github_remote_authority("github.enterprise.example/owner/repo"), + Some("github.enterprise.example".to_owned()) + ); + assert_eq!( + github_remote_authority("owner/repo"), + Some("github.com".to_owned()) + ); + } + + #[test] + fn github_remote_authority_normalizes_ssh_default_port() { + assert_eq!( + github_remote_authority("ssh://git@ghe.example:22/owner/repo.git"), + Some("ghe.example".to_owned()) + ); + assert_eq!( + github_remote_authority("ssh://git@ghe.example:2222/owner/repo.git"), + Some("ghe.example:2222".to_owned()) + ); + } + fn sample_workflow(base_dir: &Path, workspace_root: &Path) -> ResolvedWorkflow { sample_workflow_with_prompt( base_dir, @@ -10048,6 +12092,7 @@ Run the scheduler. }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: Vec::new(), project_id: None, @@ -10617,6 +12662,7 @@ exit 64 state_kind: tracker_issue_state_kind_from_category(&issue.state.category), branch_name: issue.branch_name.clone(), pr_url: issue.pr_url.clone(), + pr_urls: issue.pr_urls.clone(), labels: issue.labels.clone(), project_id: issue.project_id.clone(), project_slug: issue.project_slug.clone(), diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index 9afcd2a0c..bbb747af3 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -519,11 +519,7 @@ pub(super) async fn resolve_runtime_config( })?; if looks_like_central_config(&raw) { let central = resolve_central_config(path, &raw)?; - let repository_checkouts = matches!( - central.repository_routing.mode, - RepositoryRoutingMode::ProjectSet - ) - .then_some(central.repository_checkouts); + let repository_checkouts = Some(central.repository_checkouts); ( central.runtime, central.generation, @@ -1092,7 +1088,18 @@ fn resolve_central_config( }); } if let Some(merge_method) = profile.merge_method.as_deref() { - required_literal(merge_method, "review_profiles.merge_method")?; + let merge_method = required_literal(merge_method, "review_profiles.merge_method")?; + let known_method = matches!( + merge_method.to_ascii_lowercase().as_str(), + "merge" | "squash" | "rebase" + ); + let unsupported_github_method = profile.provider.eq_ignore_ascii_case("github") + && !merge_method.eq_ignore_ascii_case("merge"); + if !known_method || unsupported_github_method { + return Err(CentralConfigError::InvalidReference { + field: format!("review_profiles.{profile_id}.merge_method"), + }); + } } let _ = (profile.required_checks, profile.required_review); } @@ -1334,6 +1341,44 @@ fn resolve_central_config( } } validate_active_repository_aliases(&config, &active_repositories)?; + for repository_id in &active_repositories { + let repository = config.repositories.get(repository_id).ok_or_else(|| { + CentralConfigError::InvalidReference { + field: format!("repositories.{repository_id}"), + } + })?; + let review_profile = config + .review_profiles + .get(&repository.review_profile) + .ok_or_else(|| CentralConfigError::InvalidReference { + field: format!("repositories.{repository_id}.review_profile"), + })?; + let github_merge_evidence_required = mode == CentralRoutingMode::ProjectSet + || repository.remote.provider.eq_ignore_ascii_case("github") + || review_profile.required_checks + || review_profile.required_review; + if github_merge_evidence_required && !review_profile.provider.eq_ignore_ascii_case("github") + { + return Err(CentralConfigError::InvalidReference { + field: format!("review_profiles.{}.provider", repository.review_profile), + }); + } + if repository.remote.provider.eq_ignore_ascii_case("github") + || review_profile.provider.eq_ignore_ascii_case("github") + { + let credential = config + .credentials + .get(&review_profile.credential) + .ok_or_else(|| CentralConfigError::InvalidReference { + field: format!("review_profiles.{}.credential", repository.review_profile), + })?; + if credential.kind != "environment" || credential.variable.is_none() { + return Err(CentralConfigError::InvalidReference { + field: format!("review_profiles.{}.credential", repository.review_profile), + }); + } + } + } let legacy_repository_instruction_path = if mode == CentralRoutingMode::LegacySingle { let repository = config @@ -1402,12 +1447,21 @@ fn reject_checkout_credential_env_reuse( let mut checkout_variables = config .repositories .values() - .filter_map(|repository| { - config - .credentials - .get(&repository.credential) - .and_then(|credential| credential.variable.as_deref()) - .map(str::to_owned) + .flat_map(|repository| { + [ + config + .credentials + .get(&repository.credential) + .and_then(|credential| credential.variable.as_deref()), + config + .review_profiles + .get(&repository.review_profile) + .and_then(|profile| config.credentials.get(&profile.credential)) + .and_then(|credential| credential.variable.as_deref()), + ] + .into_iter() + .flatten() + .map(str::to_owned) }) .collect::>(); if config.repositories.values().any(|repository| { @@ -1590,6 +1644,10 @@ fn build_repository_checkouts( field: format!("review_profiles.{}", repository.review_profile), } })?); + let review_credential_env = config + .credentials + .get(&review_profile.credential) + .and_then(|credential| credential.variable.clone()); let checkout = CheckoutRepository { provider: repository.remote.provider.clone(), provider_id: repository.remote.provider_id.clone(), @@ -1606,11 +1664,15 @@ fn build_repository_checkouts( .get(&repository.credential) .and_then(|credential| credential.reference.clone()), credential_env, + review_credential_env, instructions_path: PathBuf::from(&repository.instructions.path), policy_generation: policy_generation.clone(), review_profile: repository.review_profile.clone(), review_provider: review_profile.provider.clone(), review_policy_generation, + required_checks: review_profile.required_checks, + required_review: review_profile.required_review, + merge_method: review_profile.merge_method.clone(), }; if checkouts .insert(identity.to_string(), checkout.clone()) @@ -2873,13 +2935,16 @@ credentials: variable: LINEAR_API_KEY github-ssh: kind: ssh-agent + github-review: + kind: environment + variable: GITHUB_TOKEN review_profiles: github-standard: provider: github - credential: github-ssh + credential: github-review required_checks: true required_review: true - merge_method: squash + merge_method: merge workspace: root: {root}/workspace memory: @@ -3192,6 +3257,26 @@ scheduler: } } + #[test] + fn central_config_rejects_review_credential_reuse_by_runtime_env() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write( + root.path().join("integration.md"), + "integration instructions\n", + ) + .expect("integration instructions should be written"); + let source = central_fixture(root.path()) + .replace(" variable: GITHUB_TOKEN", " variable: LLM_API_KEY"); + + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("review credentials must not reuse runtime environment variables"); + assert!(matches!( + error, + CentralConfigError::InvalidReference { field } + if field == "openhands.implicit_llm_env" + )); + } + #[test] fn central_config_rejects_ssh_agent_socket_reuse_by_routing_selector() { let root = tempfile::tempdir().expect("central config root should exist"); @@ -3443,10 +3528,9 @@ scheduler: scheduler_checkout.review_policy_generation ); - let review_source = base_source.replace( - "review_profiles:\n github-standard:\n provider: github", - "review_profiles:\n github-standard:\n provider: gitlab", - ); + let review_source = base_source + .replace("required_checks: true", "required_checks: false") + .replace("required_review: true", "required_review: false"); let review = resolve_central_config(&root.path().join("review.yaml"), &review_source) .expect("review policy edit should resolve"); let review_checkout = review @@ -3462,7 +3546,7 @@ scheduler: base_checkout.review_policy_generation, review_checkout.review_policy_generation ); - assert_eq!(review_checkout.review_provider, "gitlab"); + assert_eq!(review_checkout.review_provider, "github"); } #[test] @@ -3868,6 +3952,91 @@ scheduler: assert!(matches!(error, CentralConfigError::InvalidReference { .. })); } + #[test] + fn central_config_rejects_non_environment_github_review_credentials() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write(root.path().join("integration.md"), "integration\n") + .expect("integration instructions should be written"); + let source = central_fixture(root.path()).replace( + " credential: github-review", + " credential: github-ssh", + ); + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("GitHub review credentials must resolve to an environment token"); + assert!(matches!( + error, + CentralConfigError::InvalidReference { field } + if field == "review_profiles.github-standard.credential" + )); + } + + #[test] + fn central_config_rejects_unsupported_active_review_provider() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write(root.path().join("integration.md"), "integration\n") + .expect("integration instructions should be written"); + let source = central_fixture(root.path()) + .replace( + "review_profiles:\n github-standard:\n provider: github", + "review_profiles:\n github-standard:\n provider: gitlab", + ) + .replace("required_checks: true", "required_checks: false") + .replace("required_review: true", "required_review: false"); + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("unsupported active review provider should fail"); + assert!(matches!( + error, + CentralConfigError::InvalidReference { field } + if field == "review_profiles.github-standard.provider" + )); + } + + #[test] + fn central_config_rejects_unsupported_project_set_provider_without_policy_flags() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write(root.path().join("integration.md"), "integration\n") + .expect("integration instructions should be written"); + let source = central_fixture(root.path()) + .replace( + " provider: github\n provider_id: repo-42", + " provider: git\n provider_id: repo-42", + ) + .replace( + "review_profiles:\n github-standard:\n provider: github", + "review_profiles:\n github-standard:\n provider: gitlab", + ) + .replace("required_checks: true", "required_checks: false") + .replace("required_review: true", "required_review: false"); + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("project-set merge providers must be supported"); + assert!(matches!( + error, + CentralConfigError::InvalidReference { field } + if field == "review_profiles.github-standard.provider" + )); + } + + #[test] + fn central_config_rejects_non_environment_github_review_credentials_without_policy_flags() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write(root.path().join("integration.md"), "integration\n") + .expect("integration instructions should be written"); + let source = central_fixture(root.path()) + .replace( + " credential: github-review", + " credential: github-ssh", + ) + .replace("required_checks: true", "required_checks: false") + .replace("required_review: true", "required_review: false"); + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("GitHub merge evidence still needs an API credential"); + assert!(matches!( + error, + CentralConfigError::InvalidReference { field } + if field == "review_profiles.github-standard.credential" + )); + } + #[test] fn central_config_rejects_repository_credentials_without_a_provider() { let root = tempfile::tempdir().expect("central config root should exist"); diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index 637cbc8bf..f8e7871e0 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -42,6 +42,7 @@ use crate::opensymphony_workspace::{WorkspaceError, checkout_credential_environm use chrono::{DateTime, Utc}; use clap::Args; use serde::Deserialize; +use serde_json::json; use thiserror::Error; use tokio::{ net::TcpListener, @@ -976,7 +977,11 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { "starting OpenSymphony orchestrator" ); - let mut tracker = build_tracker_backend(&runtime.workflow)?; + let mut tracker = build_tracker_backend( + &runtime.workflow, + runtime.repository_checkouts.clone().unwrap_or_default(), + runtime.repository_routing.clone(), + )?; let legacy_repository = runtime.repository_routing.as_ref().and_then(|routing| { routing .legacy_repository @@ -1435,13 +1440,31 @@ where continue; } let sequence = event.sequence; - let Some(target) = gateway_cancel_target(&event) else { - *cursor = sequence; - continue; - }; - scheduler - .interrupt_operator_cancel(target, observed_at) - .await?; + if let Some(target) = gateway_cancel_target(&event) { + scheduler + .interrupt_operator_cancel(target, observed_at) + .await?; + } else if let Some((target, accepted_generation)) = gateway_replan_request(&event) { + let outcome = match scheduler + .replan_parent_target(target, accepted_generation, observed_at) + .await + { + Ok(changed) => Ok(changed), + Err(SchedulerError::Workspace { detail }) + if detail.starts_with("cannot replan hierarchy parent ") => + { + Err(detail) + } + Err(error) => return Err(error), + }; + let outcome = gateway_replan_outcome_event(&event, outcome); + journal + .append(outcome) + .await + .map_err(|error| SchedulerError::Workspace { + detail: format!("failed to record replan outcome: {error:?}"), + })?; + } *cursor = sequence; } Ok(()) @@ -1459,6 +1482,65 @@ fn gateway_cancel_target(event: &EventRecord) -> Option<&str> { payload["target_entity"]["id"].as_str() } +fn gateway_replan_request(event: &EventRecord) -> Option<(&str, u64)> { + match &event.kind { + EventKind::GatewayActionDispatched { action } if action == "replan" => {} + _ => return None, + } + let payload = event.payload.as_ref()?; + if payload["status"] != "accepted" { + return None; + } + let target = payload["target_entity"]["id"].as_str()?; + let accepted_generation = payload["payload"]["hierarchy_generation"].as_u64()?; + Some((target, accepted_generation)) +} + +fn gateway_replan_outcome_event( + dispatch: &EventRecord, + outcome: Result, +) -> EventRecord { + let (completed, reason) = match outcome { + Ok(true) => (true, None), + Ok(false) => ( + false, + Some("target is not blocked by HierarchyChanged".to_owned()), + ), + Err(reason) => (false, Some(reason)), + }; + let status = if completed { "completed" } else { "rejected" }; + EventRecord::builder() + .actor(EventActor::system("orchestrator")) + .correlation_id_opt(dispatch.correlation_id.clone()) + .entity_refs(dispatch.entity_refs.clone()) + .kind(if completed { + EventKind::GatewayActionCompleted { + action: "replan".to_owned(), + } + } else { + EventKind::GatewayActionFailed { + action: "replan".to_owned(), + reason: reason + .clone() + .expect("a rejected replan must carry a reason"), + } + }) + .summary(if completed { + "Hierarchy replan completed" + } else { + "Hierarchy replan rejected" + }) + .payload(json!({ + "action_id": dispatch.payload.as_ref().and_then(|payload| payload.get("action_id")), + "action_kind": "replan", + "correlation_id": dispatch.correlation_id, + "status": status, + "target_entity": dispatch.payload.as_ref().and_then(|payload| payload.get("target_entity")), + "reason": reason, + })) + .build() +} + #[derive(Debug, Deserialize)] struct LinearOAuthTokenResponse { access_token: String, @@ -1825,6 +1907,88 @@ mod tests { keys.iter().map(|key| key.to_string()).collect() } + #[test] + fn gateway_replan_outcome_records_completed_or_rejected_result() { + let dispatch = EventRecord::builder() + .correlation_id("replan-correlation") + .kind(EventKind::GatewayActionDispatched { + action: "replan".to_owned(), + }) + .payload(serde_json::json!({ + "action_id": "replan-action", + "target_entity": { "id": "COE-552" }, + })) + .build(); + + let completed = gateway_replan_outcome_event(&dispatch, Ok(true)); + assert!(matches!( + completed.kind, + EventKind::GatewayActionCompleted { ref action } if action == "replan" + )); + assert_eq!( + completed.payload.as_ref().expect("completion payload")["status"], + "completed" + ); + + let rejected = gateway_replan_outcome_event(&dispatch, Ok(false)); + assert!(matches!( + rejected.kind, + EventKind::GatewayActionFailed { ref action, .. } if action == "replan" + )); + assert_eq!( + rejected.payload.as_ref().expect("rejection payload")["status"], + "rejected" + ); + assert_eq!( + rejected.payload.as_ref().expect("rejection payload")["reason"], + "target is not blocked by HierarchyChanged" + ); + + let fenced = gateway_replan_outcome_event( + &dispatch, + Err("cannot replan hierarchy parent COE-552 while execution is running".to_owned()), + ); + assert!(matches!( + fenced.kind, + EventKind::GatewayActionFailed { ref action, ref reason } + if action == "replan" + && reason == "cannot replan hierarchy parent COE-552 while execution is running" + )); + assert_eq!( + fenced.payload.as_ref().expect("fenced payload")["reason"], + "cannot replan hierarchy parent COE-552 while execution is running" + ); + } + + #[test] + fn gateway_replan_request_requires_the_displayed_hierarchy_generation() { + let dispatch = EventRecord::builder() + .correlation_id("replan-correlation") + .kind(EventKind::GatewayActionDispatched { + action: "replan".to_owned(), + }) + .payload(serde_json::json!({ + "status": "accepted", + "target_entity": { "id": "COE-552" }, + "payload": { "hierarchy_generation": 7 }, + })) + .build(); + + assert_eq!(gateway_replan_request(&dispatch), Some(("COE-552", 7))); + + let missing_generation = EventRecord::builder() + .correlation_id("replan-correlation") + .kind(EventKind::GatewayActionDispatched { + action: "replan".to_owned(), + }) + .payload(serde_json::json!({ + "status": "accepted", + "target_entity": { "id": "COE-552" }, + })) + .build(); + assert_eq!(gateway_replan_request(&missing_generation), None); + } + #[test] fn optional_task_graph_client_returns_none_when_linear_reader_is_unavailable() { let client = optional_task_graph_client(Err(LinearError::InvalidConfiguration( diff --git a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs index b12f1eb83..51ae90096 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs @@ -63,7 +63,14 @@ pub(super) fn map_snapshot( issues: snapshot .issues .iter() - .map(|issue| map_issue(issue, terminal_states, generated_at)) + .map(|issue| { + map_issue( + issue, + terminal_states, + generated_at, + snapshot.hierarchy.get(issue.issue.id.as_str()), + ) + }) .collect(), recent_events: recent_events.iter().cloned().collect(), } @@ -92,6 +99,7 @@ fn map_issue( issue: &crate::opensymphony_domain::IssueSnapshot, terminal_states: &HashSet, generated_at: DateTime, + hierarchy: Option<&crate::opensymphony_domain::HierarchyStateSnapshot>, ) -> IssueSnapshot { let runtime_state = match issue.runtime.state { SchedulerStatus::Running | SchedulerStatus::Claimed => IssueRuntimeState::Running, @@ -173,6 +181,7 @@ fn map_issue( let repository_binding_blocked = repository_binding .as_ref() .is_some_and(|binding| binding.resolved_binding().is_none()); + let hierarchy_blocked = hierarchy.and_then(|state| state.blocked_reason.clone()); IssueSnapshot { identifier: issue.issue.identifier.to_string(), @@ -253,6 +262,7 @@ fn map_issue( max_turns: worker.map(|worker| worker.max_turns).unwrap_or(0), runtime_seconds, blocked: repository_binding_blocked + || hierarchy_blocked.is_some() || issue.issue.blocked_by.iter().any(|blocker| { blocker .state @@ -266,6 +276,8 @@ fn map_issue( .iter() .any(|sub_issue| !is_terminal_state(terminal_states, &sub_issue.state))), repository_binding, + hierarchy_generation: hierarchy.map(|state| state.generation), + hierarchy_blocked_reason: hierarchy_blocked, blocked_by: issue .issue .blocked_by @@ -571,7 +583,7 @@ tracker: ) .collect(); - let snapshot = OrchestratorSnapshot::new( + let mut snapshot = OrchestratorSnapshot::new( ts(2_000), DaemonSnapshot::new( HealthStatus::Healthy, @@ -595,6 +607,7 @@ tracker: }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: Vec::new(), project_id: Some("proj-open".to_owned()), @@ -660,6 +673,13 @@ tracker: recent_worker_outcomes: Vec::new(), }], ); + snapshot.hierarchy.insert( + "lin_352".to_owned(), + crate::opensymphony_domain::HierarchyStateSnapshot { + generation: 3, + blocked_reason: Some("HierarchyChanged".to_owned()), + }, + ); let mapped = map_snapshot( &snapshot, @@ -703,6 +723,12 @@ tracker: Some("OpenSymphony") ); assert_eq!(mapped.issues[0].workspace_label.as_deref(), Some("COE-352")); + assert!(mapped.issues[0].blocked); + assert_eq!(mapped.issues[0].hierarchy_generation, Some(3)); + assert_eq!( + mapped.issues[0].hierarchy_blocked_reason.as_deref(), + Some("HierarchyChanged") + ); } #[test] @@ -747,6 +773,7 @@ tracker: }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: Vec::new(), project_id: None, diff --git a/crates/opensymphony-cli/tests/tui.rs b/crates/opensymphony-cli/tests/tui.rs index 2429d9825..8bf5e349f 100644 --- a/crates/opensymphony-cli/tests/tui.rs +++ b/crates/opensymphony-cli/tests/tui.rs @@ -84,6 +84,8 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), diff --git a/crates/opensymphony-control/src/lib.rs b/crates/opensymphony-control/src/lib.rs index 2f94572ba..dcb04cf29 100644 --- a/crates/opensymphony-control/src/lib.rs +++ b/crates/opensymphony-control/src/lib.rs @@ -462,6 +462,8 @@ mod tests { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), diff --git a/crates/opensymphony-control/tests/control_plane.rs b/crates/opensymphony-control/tests/control_plane.rs index ce309909b..433fc5b21 100644 --- a/crates/opensymphony-control/tests/control_plane.rs +++ b/crates/opensymphony-control/tests/control_plane.rs @@ -69,6 +69,8 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), diff --git a/crates/opensymphony-domain/src/control_plane.rs b/crates/opensymphony-domain/src/control_plane.rs index a0059fe8d..5bb51c1ed 100644 --- a/crates/opensymphony-domain/src/control_plane.rs +++ b/crates/opensymphony-domain/src/control_plane.rs @@ -132,6 +132,10 @@ pub struct ControlPlaneIssueSnapshot { pub runtime_seconds: u64, pub blocked: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + pub hierarchy_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hierarchy_blocked_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_binding: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocked_by: Vec, diff --git a/crates/opensymphony-domain/src/issue.rs b/crates/opensymphony-domain/src/issue.rs index 7789f93cb..88edd0001 100644 --- a/crates/opensymphony-domain/src/issue.rs +++ b/crates/opensymphony-domain/src/issue.rs @@ -43,6 +43,8 @@ pub struct NormalizedIssue { pub state: IssueState, pub branch_name: Option, pub pr_url: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pr_urls: Vec, pub url: Option, pub labels: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/opensymphony-domain/src/lib.rs b/crates/opensymphony-domain/src/lib.rs index e48eadb31..456555e6f 100644 --- a/crates/opensymphony-domain/src/lib.rs +++ b/crates/opensymphony-domain/src/lib.rs @@ -42,8 +42,9 @@ pub use runtime::{ StallMetadata, StreamHealth, WorkerOutcomeKind, WorkerOutcomeRecord, WorkspaceRecord, }; pub use snapshot::{ - ComponentHealthSnapshot, DaemonSnapshot, HealthStatus, IssueSnapshot, OrchestratorSnapshot, - RetrySnapshot, RuntimeStateSnapshot, RuntimeUsageTotals, WorkerAttemptSnapshot, + ComponentHealthSnapshot, DaemonSnapshot, HealthStatus, HierarchyStateSnapshot, IssueSnapshot, + OrchestratorSnapshot, RetrySnapshot, RuntimeStateSnapshot, RuntimeUsageTotals, + WorkerAttemptSnapshot, }; pub use state_machine::{ IssueExecution, SchedulerState, SchedulerStatus, StateTransitionError, TransitionAction, @@ -139,6 +140,7 @@ mod tests { "leonardogonzalez/coe-260-domain-model-and-orchestrator-state-machine".to_owned(), ), pr_url: None, + pr_urls: Vec::new(), url: Some( "https://linear.app/trilogy-ai-coe/issue/COE-260/domain-model-and-orchestrator-state-machine" .to_owned(), diff --git a/crates/opensymphony-domain/src/snapshot.rs b/crates/opensymphony-domain/src/snapshot.rs index a8fdb4e0c..aa3e773ab 100644 --- a/crates/opensymphony-domain/src/snapshot.rs +++ b/crates/opensymphony-domain/src/snapshot.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use super::{ ConversationMetadata, HarnessInterruptState, IssueExecution, NormalizedIssue, ReleaseReason, @@ -205,6 +206,13 @@ pub struct IssueSnapshot { pub recent_worker_outcomes: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HierarchyStateSnapshot { + pub generation: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_reason: Option, +} + impl From<&IssueExecution> for IssueSnapshot { fn from(execution: &IssueExecution) -> Self { Self { @@ -225,6 +233,8 @@ pub struct OrchestratorSnapshot { pub generated_at: TimestampMs, pub daemon: DaemonSnapshot, pub issues: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub hierarchy: BTreeMap, } impl OrchestratorSnapshot { @@ -250,6 +260,7 @@ impl OrchestratorSnapshot { ..daemon }, issues, + hierarchy: BTreeMap::new(), } } } diff --git a/crates/opensymphony-domain/src/state_machine.rs b/crates/opensymphony-domain/src/state_machine.rs index a92f93b30..1c9dbf4f5 100644 --- a/crates/opensymphony-domain/src/state_machine.rs +++ b/crates/opensymphony-domain/src/state_machine.rs @@ -253,6 +253,14 @@ impl IssueExecution { &self.recent_worker_outcomes } + /// Retain a worker outcome while the scheduler retries a fallible + /// terminal transition. This does not change the scheduler state; it + /// preserves the completed evidence so a persistence failure cannot make + /// a finished worker look as though it never reported an outcome. + pub fn retain_worker_outcome(&mut self, outcome: WorkerOutcomeRecord) { + self.record_outcome(outcome); + } + pub fn retry_count_override(&self) -> Option { self.retry_count_override } diff --git a/crates/opensymphony-domain/src/tracker.rs b/crates/opensymphony-domain/src/tracker.rs index 0757ebc33..f185f8908 100644 --- a/crates/opensymphony-domain/src/tracker.rs +++ b/crates/opensymphony-domain/src/tracker.rs @@ -18,6 +18,11 @@ pub struct TrackerIssue { pub branch_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pr_url: Option, + /// All canonical pull-request attachments, retained so provider-backed + /// eligibility can select evidence for the current child run rather than + /// trusting the first historical attachment. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pr_urls: Vec, pub labels: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_id: Option, @@ -116,6 +121,8 @@ pub struct TrackerIssueRef { #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, pub state: String, + #[serde(default = "unknown_tracker_state_kind")] + pub state_kind: TrackerIssueStateKind, } impl TrackerIssueRef { @@ -161,6 +168,18 @@ impl TrackerIssueStateKind { pub fn is_terminal(&self) -> bool { matches!(self, Self::Completed | Self::Canceled) } + + pub fn from_tracker_name(value: impl AsRef) -> Self { + match value.as_ref().trim().to_ascii_lowercase().as_str() { + "backlog" => Self::Backlog, + "todo" | "to do" => Self::Unstarted, + "in progress" | "started" | "review" | "human review" | "merging" => Self::Started, + "done" | "completed" | "closed" => Self::Completed, + "canceled" | "cancelled" => Self::Canceled, + "triage" | "triaged" => Self::Triage, + other => Self::Unknown(other.to_owned()), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -264,6 +283,7 @@ mod tests { title: None, url: None, state: "done".to_string(), + state_kind: TrackerIssueStateKind::Completed, }; let terminal_states = HashSet::from([String::from("Done"), String::from("Canceled")]); diff --git a/crates/opensymphony-domain/tests/snapshot_serialization.rs b/crates/opensymphony-domain/tests/snapshot_serialization.rs index 0b1968e5d..59d8cb682 100644 --- a/crates/opensymphony-domain/tests/snapshot_serialization.rs +++ b/crates/opensymphony-domain/tests/snapshot_serialization.rs @@ -65,6 +65,8 @@ fn fixture() -> SnapshotEnvelope { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("https://agent.example.com/runtime".to_owned()), diff --git a/crates/opensymphony-gateway-schema/src/action.rs b/crates/opensymphony-gateway-schema/src/action.rs index 9883c3de2..e1440f526 100644 --- a/crates/opensymphony-gateway-schema/src/action.rs +++ b/crates/opensymphony-gateway-schema/src/action.rs @@ -76,6 +76,9 @@ pub enum ExpectedFollowup { pub enum ActionKind { Retry, Cancel, + /// Accept the current hierarchy snapshot and clear a parent + /// `HierarchyChanged` block for re-planning. + Replan, Pause, Resume, Rehydrate, @@ -106,6 +109,7 @@ impl std::fmt::Display for ActionKind { let s = match self { ActionKind::Retry => "retry", ActionKind::Cancel => "cancel", + ActionKind::Replan => "replan", ActionKind::Pause => "pause", ActionKind::Resume => "resume", ActionKind::Rehydrate => "rehydrate", @@ -139,6 +143,10 @@ impl ActionKind { ExpectedFollowup::ActionCompletion, ExpectedFollowup::RunLifecycle, ], + ActionKind::Replan => vec![ + ExpectedFollowup::ActionCompletion, + ExpectedFollowup::StateTransition, + ], ActionKind::Pause => vec![ ExpectedFollowup::ActionCompletion, ExpectedFollowup::StateTransition, diff --git a/crates/opensymphony-gateway-schema/src/run.rs b/crates/opensymphony-gateway-schema/src/run.rs index 945183c9b..e5001e7a2 100644 --- a/crates/opensymphony-gateway-schema/src/run.rs +++ b/crates/opensymphony-gateway-schema/src/run.rs @@ -74,6 +74,11 @@ pub struct RunDetail { /// Blocker description when the run is blocked. #[serde(default, skip_serializing_if = "Option::is_none")] pub blocker: Option, + /// Current durable hierarchy generation, when this run is a parent whose + /// child scope is versioned. Clients must send this value with `replan` + /// actions so stale operator views cannot clear a newer fence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hierarchy_generation: Option, pub error: Option, /// Actions the client may perform on this run. #[serde(default)] @@ -109,6 +114,7 @@ pub struct RunDetail { #[serde(rename_all = "snake_case")] pub enum RunAction { Retry, + Replan, Cancel, Pause, Resume, @@ -214,6 +220,10 @@ pub struct SafeActions { pub rehydrate: bool, #[serde(default)] pub detach: bool, + /// True when the parent is fenced by `HierarchyChanged` and can accept a + /// generation-checked replan action. + #[serde(default)] + pub replan: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 8d263e8f8..40b7c403b 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -566,6 +566,7 @@ fn run_detail_roundtrips() { codex_thread_id: None, summary: Some("Processing run".into()), blocker: None, + hierarchy_generation: Some(3), error: None, allowed_actions: vec![], liveness: None, @@ -1650,6 +1651,7 @@ fn safe_actions_roundtrips() { cancel: false, rehydrate: true, detach: false, + replan: true, }; let json = must_serialize(&actions); let back: SafeActions = must_deserialize(&json); @@ -1663,6 +1665,7 @@ fn safe_actions_defaults_to_all_false() { assert!(!actions.cancel); assert!(!actions.rehydrate); assert!(!actions.detach); + assert!(!actions.replan); } #[test] diff --git a/crates/opensymphony-gateway/src/action_handler.rs b/crates/opensymphony-gateway/src/action_handler.rs index 21ddb32ce..4914b11b4 100644 --- a/crates/opensymphony-gateway/src/action_handler.rs +++ b/crates/opensymphony-gateway/src/action_handler.rs @@ -26,7 +26,14 @@ pub struct ValidatedAction { pub struct ActionHandler { journal: InMemoryEventJournal, permission_checker: Option>, - idempotency_guard: Arc>>, + idempotency_guard: Arc>>, +} + +#[derive(Debug, Clone)] +struct IdempotencyRecord { + fingerprint: String, + permission: PermissionResult, + receipt: ActionReceipt, } impl Clone for ActionHandler { @@ -95,22 +102,47 @@ impl ActionHandler { // lock to prevent TOCTOU races under concurrent load. if let Some(key) = action.idempotency_key.clone() { let mut guard = self.idempotency_guard.write().await; - if guard.get(&key).is_some() { + let permission = self.permission_for(&action); + if !permission.allowed { + return permission_denied_receipt(&action, permission); + } + let fingerprint = action_fingerprint(&action); + if let Some(record) = guard.get(&key) { + if record.fingerprint == fingerprint && record.permission == permission { + return record.receipt.clone(); + } return ActionReceipt::rejected( Uuid::new_v4().to_string(), - action.correlation_id, + action.correlation_id.clone(), action.action_kind, - "duplicate idempotency key", - ); + "duplicate idempotency key: different action or authorization context", + ) + .with_permission(permission); } - let receipt = self.dispatch_unlocked(action, snapshot).await; + let receipt = self + .dispatch_unlocked(action, snapshot, permission.clone()) + .await; if receipt.status == ActionStatus::Accepted { - guard.put(key, ()); + guard.put( + key, + IdempotencyRecord { + fingerprint, + permission, + receipt: receipt.clone(), + }, + ); } return receipt; } - self.dispatch_unlocked(action, snapshot).await + let permission = self.permission_for(&action); + self.dispatch_unlocked(action, snapshot, permission).await + } + + fn permission_for(&self, action: &ActionDispatch) -> PermissionResult { + self.permission_checker + .as_ref() + .map_or_else(PermissionResult::local, |checker| checker.check(action)) } /// Core dispatch logic without idempotency locking. @@ -118,24 +150,10 @@ impl ActionHandler { &self, action: ActionDispatch, snapshot: &SnapshotEnvelope, + permission: PermissionResult, ) -> ActionReceipt { - let permission = self - .permission_checker - .as_ref() - .map_or_else(PermissionResult::local, |checker| checker.check(&action)); - if !permission.allowed { - let mut receipt = ActionReceipt::rejected( - Uuid::new_v4().to_string(), - action.correlation_id.clone(), - action.action_kind, - format!( - "permission denied: required role {}", - permission.required_role - ), - ); - receipt = receipt.with_permission(permission); - return receipt; + return permission_denied_receipt(&action, permission); } let issue = match action.target_entity.entity_kind { @@ -150,6 +168,7 @@ impl ActionHandler { let validated = match action.action_kind { ActionKind::Retry => validate_retry(&action, issue.as_ref(), &action_id), ActionKind::Cancel => validate_cancel(&action, issue.as_ref(), &action_id), + ActionKind::Replan => validate_replan(&action, issue.as_ref(), &action_id), ActionKind::Rehydrate => validate_rehydrate(&action, issue.as_ref(), &action_id), ActionKind::Comment => validate_comment(&action, issue.as_ref(), &action_id), ActionKind::Pause => validate_pause(&action, issue.as_ref(), &action_id), @@ -187,6 +206,29 @@ impl ActionHandler { } } +fn permission_denied_receipt( + action: &ActionDispatch, + permission: PermissionResult, +) -> ActionReceipt { + ActionReceipt::rejected( + Uuid::new_v4().to_string(), + action.correlation_id.clone(), + action.action_kind, + format!( + "permission denied: required role {}", + permission.required_role + ), + ) + .with_permission(permission) +} + +fn action_fingerprint(action: &ActionDispatch) -> String { + let mut action = action.clone(); + action.idempotency_key = None; + action.correlation_id.clear(); + serde_json::to_string(&action).expect("action dispatch should be serializable") +} + fn find_issue_by_id( snapshot: &ControlPlaneDaemonSnapshot, entity_id: &str, @@ -208,6 +250,7 @@ fn is_run_action_safe(issue: &ControlPlaneIssueSnapshot, action: RunAction) -> b RunAction::Cancel => safe.cancel, RunAction::Rehydrate => safe.rehydrate, RunAction::Detach => safe.detach, + RunAction::Replan => safe.replan, // Pause and Resume are validated by their own dedicated validators // (validate_pause and validate_resume) and are never routed here. // Comment, follow-up, workspace, and debug are not gated by SafeActions; @@ -292,6 +335,59 @@ fn validate_cancel( ) } +fn validate_replan( + action: &ActionDispatch, + issue: Option<&ControlPlaneIssueSnapshot>, + action_id: &str, +) -> ValidatedAction { + let Some(issue) = issue else { + return reject(action, action_id, "target issue not found in snapshot"); + }; + let has_hierarchy_generation = action + .payload + .as_ref() + .and_then(|payload| payload.get("hierarchy_generation")) + .and_then(serde_json::Value::as_u64) + .is_some(); + if !has_hierarchy_generation { + return reject( + action, + action_id, + "replan requires a numeric hierarchy_generation payload", + ); + } + if !is_run_action_safe(issue, RunAction::Replan) { + return reject( + action, + action_id, + format!( + "replan unsafe for issue {}; only a HierarchyChanged parent may be replanned", + issue.identifier + ), + ); + } + let accepted_generation = action + .payload + .as_ref() + .and_then(|payload| payload.get("hierarchy_generation")) + .and_then(serde_json::Value::as_u64); + if accepted_generation != issue.hierarchy_generation { + return reject( + action, + action_id, + "replan hierarchy_generation does not match the current run contract", + ); + } + accepted( + action, + action_id, + issue, + EventKind::GatewayActionDispatched { + action: "replan".into(), + }, + ) +} + fn validate_rehydrate( action: &ActionDispatch, issue: Option<&ControlPlaneIssueSnapshot>, @@ -601,6 +697,7 @@ fn build_audit_event( "kind": format!("{:?}", action.target_entity.entity_kind).to_lowercase(), "id": action.target_entity.entity_id, }, + "payload": action.payload, })) .build() } diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index cd249b266..0b6cc5aa5 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -4531,10 +4531,13 @@ fn build_runtime_overlay( )); let blocker_summary = if issue.blocked { - issue - .repository_binding - .as_ref() - .map(repository_binding_diagnostic) + hierarchy_blocker_diagnostic(issue) + .or_else(|| { + issue + .repository_binding + .as_ref() + .map(repository_binding_diagnostic) + }) .or_else(|| Some("Blocked by dependency".into())) } else { None @@ -4560,6 +4563,15 @@ fn build_runtime_overlay( } } +fn hierarchy_blocker_diagnostic(issue: &ControlPlaneIssueSnapshot) -> Option { + issue.hierarchy_blocked_reason.as_ref().map(|reason| { + format!( + "Hierarchy blocked: {reason} (generation {})", + issue.hierarchy_generation.unwrap_or_default() + ) + }) +} + fn repository_binding_diagnostic(outcome: &RepositoryBindingOutcome) -> String { match outcome { RepositoryBindingOutcome::Resolved(_) => "Repository binding resolved".to_owned(), @@ -4628,6 +4640,7 @@ async fn get_run_detail( codex_thread_id: None, summary: None, blocker: None, + hierarchy_generation: None, error: Some("Run not found".into()), allowed_actions: Vec::new(), liveness: None, @@ -4777,12 +4790,16 @@ async fn get_run_detail( codex_thread_id: issue.codex_thread_id.clone(), summary: None, blocker: issue.blocked.then(|| { - issue - .repository_binding - .as_ref() - .map(repository_binding_diagnostic) + hierarchy_blocker_diagnostic(issue) + .or_else(|| { + issue + .repository_binding + .as_ref() + .map(repository_binding_diagnostic) + }) .unwrap_or_else(|| "Blocked by dependency".into()) }), + hierarchy_generation: issue.hierarchy_generation, error: None, allowed_actions: allowed_actions_for_issue(issue, dispatchable), liveness: Some(build_liveness(issue)), @@ -5403,6 +5420,11 @@ fn allowed_actions_for_issue( allowed.push(RunAction::Comment); allowed.push(RunAction::CreateFollowup); } + if issue.hierarchy_blocked_reason.as_deref() == Some("HierarchyChanged") + && issue.hierarchy_generation.is_some() + { + allowed.push(RunAction::Replan); + } // OpenWorkspace is available when there is a local workspace path. if !issue.workspace_path_suffix.is_empty() { allowed.push(RunAction::OpenWorkspace); @@ -5449,12 +5471,15 @@ pub(crate) fn safe_actions_for_issue(issue: &ControlPlaneIssueSnapshot) -> SafeA // (stalled, degraded, or detached) and not already detached. let stream = build_liveness(issue).stream; let detach = !matches!(stream, RunStreamLiveness::Healthy) && !issue.detached; + let replan = issue.hierarchy_blocked_reason.as_deref() == Some("HierarchyChanged") + && issue.hierarchy_generation.is_some(); SafeActions { retry, cancel, rehydrate, detach, + replan, } } @@ -6217,6 +6242,25 @@ exit 2 assert!(!actions.contains(&RunAction::Resume)); } + #[test] + fn hierarchy_changed_parent_exposes_generation_checked_replan() { + let mut issue = test_issue( + ControlPlaneIssueRuntimeState::Idle, + TestIssueFlags { + workspace: false, + harness: false, + detached: false, + }, + ); + issue.blocked = true; + issue.hierarchy_generation = Some(7); + issue.hierarchy_blocked_reason = Some("HierarchyChanged".to_owned()); + + let actions = allowed_actions_for_issue(&issue, true); + assert!(actions.contains(&RunAction::Replan)); + assert!(safe_actions_for_issue(&issue).replan); + } + #[test] fn allowed_actions_detach_matches_stream_health() { let stalled = test_issue( @@ -6366,6 +6410,8 @@ exit 2 max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: if flags.harness { diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index a04540160..2fc28e5e6 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -199,6 +199,7 @@ fn tracker_issue_from_snapshot( state_kind: tracker_state_kind_from_name(&issue.tracker_state), branch_name: issue.branch_name.clone(), pr_url: issue.pr_url.clone(), + pr_urls: Vec::new(), labels: Vec::new(), project_id: issue.project_id.clone(), project_slug: issue.project_slug.clone(), @@ -233,6 +234,7 @@ fn tracker_issue_ref_from_tracker(issue: &TrackerIssue) -> TrackerIssueRef { title: Some(issue.title.clone()), url: Some(issue.url.clone()), state: issue.state.clone(), + state_kind: issue.state_kind.clone(), } } @@ -243,6 +245,7 @@ fn tracker_issue_ref_from_identifier(identifier: &str) -> TrackerIssueRef { title: Some(format!("External {identifier}")), url: None, state: "Todo".to_owned(), + state_kind: TrackerIssueStateKind::Unstarted, } } @@ -736,6 +739,8 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { max_turns: 8, runtime_seconds: 75, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), @@ -829,6 +834,8 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: None, @@ -875,6 +882,8 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 70, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3001".to_owned()), @@ -972,6 +981,8 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 20, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3002".to_owned()), @@ -1018,6 +1029,8 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3003".to_owned()), @@ -1064,6 +1077,8 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: true, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: vec!["COE-300".to_owned()], server_base_url: None, @@ -3926,6 +3941,7 @@ async fn gateway_task_graph_includes_backlog_issues_with_cross_edges() { state_kind: TrackerIssueStateKind::Backlog, branch_name: None, pr_url: None, + pr_urls: Vec::new(), labels: Vec::new(), project_id: Some("proj-open".to_owned()), project_slug: Some("opensymphony-bootstrap".to_owned()), @@ -3961,6 +3977,7 @@ async fn gateway_task_graph_includes_backlog_issues_with_cross_edges() { state_kind: TrackerIssueStateKind::Unstarted, branch_name: None, pr_url: None, + pr_urls: Vec::new(), labels: Vec::new(), project_id: Some("proj-open".to_owned()), project_slug: Some("opensymphony-bootstrap".to_owned()), @@ -5966,24 +5983,83 @@ async fn gateway_dispatches_action_and_returns_receipt() { "action_id should be non-empty: {:?}", body.action_id ); + let first_action_id = body.action_id.clone(); + + let generationless_replan = ActionDispatch { + schema_version: Default::default(), + correlation_id: "corr_replan_missing_generation".to_string(), + action_kind: ActionKind::Replan, + target_entity: ActionTarget { + entity_kind: EntityKind::Issue, + entity_id: "COE-255".to_string(), + }, + payload: None, + idempotency_key: None, + }; + let response = client + .post(&url) + .json(&generationless_replan) + .send() + .await + .expect("generation-less replan should respond"); + assert_eq!(response.status(), 400); + let body: ActionReceipt = response.json().await.expect("should not be None"); + assert_eq!(body.status, ActionStatus::Rejected); + assert!( + body.reason + .as_deref() + .is_some_and(|reason| reason.contains("hierarchy_generation")) + ); - // Duplicate idempotency key → rejected receipt + // Retried idempotency key → replay of the original accepted receipt let response = client .post(&url) .json(&dispatch) .send() .await .expect("POST /api/v1/actions/dispatch should respond"); + assert_eq!(response.status(), 200); + let body: ActionReceipt = response.json().await.expect("should not be None"); + assert_eq!(body.status, ActionStatus::Accepted); + assert_eq!(body.action_id, first_action_id); + assert_eq!(body.correlation_id, "corr_001"); + + // Retried idempotency key with a fresh correlation ID → same receipt + let correlation_retry = ActionDispatch { + correlation_id: "corr_retry".to_owned(), + ..dispatch.clone() + }; + let response = client + .post(&url) + .json(&correlation_retry) + .send() + .await + .expect("correlation-only retry should respond"); + assert_eq!(response.status(), 200); + let body: ActionReceipt = response.json().await.expect("should not be None"); + assert_eq!(body.status, ActionStatus::Accepted); + assert_eq!(body.action_id, first_action_id); + assert_eq!(body.correlation_id, "corr_001"); + + // Reusing a key for a different action must not replay the original receipt. + let mismatched_dispatch = ActionDispatch { + action_kind: ActionKind::Comment, + correlation_id: "corr_mismatched_key".to_owned(), + ..dispatch.clone() + }; + let response = client + .post(&url) + .json(&mismatched_dispatch) + .send() + .await + .expect("mismatched idempotency key should respond"); assert_eq!(response.status(), 409); let body: ActionReceipt = response.json().await.expect("should not be None"); assert_eq!(body.status, ActionStatus::Rejected); assert!( body.reason - .as_ref() - .expect("should not be None") - .contains("duplicate idempotency key"), - "rejected reason should mention duplicate idempotency key: {:?}", - body.reason + .as_deref() + .is_some_and(|reason| reason.contains("different action")) ); // Invalid retry action (already active) → rejected receipt diff --git a/crates/opensymphony-linear/src/client.rs b/crates/opensymphony-linear/src/client.rs index 92aaae1bc..b8c4200e9 100644 --- a/crates/opensymphony-linear/src/client.rs +++ b/crates/opensymphony-linear/src/client.rs @@ -20,13 +20,14 @@ use super::error::{GraphqlError, LinearError, ResponseMetadata}; use super::graphql::{ COMMENT_CREATE_MUTATION, CommentCreateData, CommentCreateInput, CommentCreateVariables, GraphqlEnvelope, GraphqlErrorPayload, ISSUE_ARCHIVE_MUTATION, ISSUE_BY_IDENTIFIER_QUERY, - ISSUE_COMMENTS_QUERY, ISSUE_CREATE_MUTATION, ISSUE_INVERSE_RELATIONS_QUERY, ISSUE_LABELS_QUERY, - ISSUE_RELATION_CREATE_MUTATION, ISSUE_STATES_BY_IDS_QUERY, ISSUE_STATES_BY_IDS_UNSCOPED_QUERY, - ISSUE_SUMMARIES_BY_STATE_QUERY, ISSUE_UPDATE_MUTATION, ISSUES_BY_STATE_QUERY, IssueArchiveData, - IssueArchiveVariables, IssueByIdentifierData, IssueByIdentifierVariables, IssueCommentsData, - IssueCommentsVariables, IssueCreateData, IssueCreateInput, IssueCreateVariables, - IssueInverseRelationsData, IssueInverseRelationsVariables, IssueLabelsData, - IssueLabelsVariables, IssueRelationCreateData, IssueRelationCreateInput, + ISSUE_CHILDREN_QUERY, ISSUE_COMMENTS_QUERY, ISSUE_CREATE_MUTATION, + ISSUE_INVERSE_RELATIONS_QUERY, ISSUE_LABELS_QUERY, ISSUE_RELATION_CREATE_MUTATION, + ISSUE_STATES_BY_IDS_QUERY, ISSUE_STATES_BY_IDS_UNSCOPED_QUERY, ISSUE_SUMMARIES_BY_STATE_QUERY, + ISSUE_UPDATE_MUTATION, ISSUES_BY_STATE_QUERY, IssueArchiveData, IssueArchiveVariables, + IssueByIdentifierData, IssueByIdentifierVariables, IssueChildrenData, IssueChildrenVariables, + IssueCommentsData, IssueCommentsVariables, IssueCreateData, IssueCreateInput, + IssueCreateVariables, IssueInverseRelationsData, IssueInverseRelationsVariables, + IssueLabelsData, IssueLabelsVariables, IssueRelationCreateData, IssueRelationCreateInput, IssueRelationCreateVariables, IssueRelationMutationNode, IssueStatesByIdsData, IssueStatesByIdsUnscopedVariables, IssueStatesByIdsVariables, IssueSummariesByStateData, IssueSummariesByStateVariables, IssueUpdateData, IssueUpdateInput, IssueUpdateVariables, @@ -405,7 +406,7 @@ impl LinearClient { continue; }; let issue = normalize_issue(self.expand_issue(issue).await?)?; - if issue.identifier.eq_ignore_ascii_case(identifier) { + if issue.identifier.eq_ignore_ascii_case(identifier) || issue.id == *identifier { issues.push(issue); } else { return Err(LinearError::InvalidResponse(format!( @@ -1238,6 +1239,11 @@ impl LinearClient { &self, mut issue: LinearIssueNode, ) -> Result { + // ponytail: history is capped at 32 recent PR links; use lazy lookup if older evidence is needed. + if issue.attachments.page_info.has_next_page { + debug!(issue_id = %issue.id, "historical PR attachments truncated to the latest 32 candidates"); + } + issue.children = self.load_all_children(&issue.id, issue.children).await?; issue.labels = self.load_all_labels(&issue.id, issue.labels).await?; issue.inverse_relations = self .load_all_inverse_relations(&issue.id, issue.inverse_relations) @@ -1245,6 +1251,45 @@ impl LinearClient { Ok(issue) } + async fn load_all_children( + &self, + issue_id: &str, + mut connection: super::graphql::LinearChildConnection, + ) -> Result { + let mut after = connection.page_info.end_cursor.clone(); + + while connection.page_info.has_next_page { + let cursor = after.clone().ok_or_else(|| { + LinearError::InvalidResponse(format!( + "Linear children page for issue {issue_id} indicated a next page without an end cursor" + )) + })?; + let variables = IssueChildrenVariables { + issue_id: issue_id.to_string(), + first: self.config.page_size, + after: Some(cursor), + }; + let response: IssueChildrenData = self + .execute_graphql(ISSUE_CHILDREN_QUERY, json!(variables)) + .await?; + let issue = response.issue.ok_or_else(|| LinearError::MissingIssueIds { + issue_ids: vec![issue_id.to_string()], + })?; + if issue.id != issue_id { + return Err(LinearError::InvalidResponse(format!( + "Linear children page returned mismatched issue ID {} for {}", + issue.id, issue_id + ))); + } + + connection.nodes.extend(issue.children.nodes); + connection.page_info = issue.children.page_info; + after = connection.page_info.end_cursor.clone(); + } + + Ok(connection) + } + async fn load_all_labels( &self, issue_id: &str, @@ -1475,7 +1520,7 @@ impl LinearClient { LinearError::Request(_) => true, LinearError::ResponseBody { .. } => true, LinearError::HttpStatus { status, .. } => { - *status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() + error.is_rate_limited() || status.is_server_error() } LinearError::Graphql { .. } => error.is_rate_limited(), LinearError::MissingIssueIds { .. } diff --git a/crates/opensymphony-linear/src/error.rs b/crates/opensymphony-linear/src/error.rs index 54905b509..44bd3d4ea 100644 --- a/crates/opensymphony-linear/src/error.rs +++ b/crates/opensymphony-linear/src/error.rs @@ -99,7 +99,7 @@ impl LinearError { Self::ResponseBody { .. } => TrackerErrorCategory::Transport, Self::Request(error) if error.is_timeout() => TrackerErrorCategory::Timeout, Self::Request(_) => TrackerErrorCategory::Transport, - Self::HttpStatus { status, .. } => http_status_category(*status), + Self::HttpStatus { status, body, .. } => http_status_category(*status, body), Self::Graphql { errors, .. } => graphql_category(errors), } } @@ -118,9 +118,12 @@ impl LinearError { } } -fn http_status_category(status: StatusCode) -> TrackerErrorCategory { +fn http_status_category(status: StatusCode, body: &str) -> TrackerErrorCategory { match status { StatusCode::UNAUTHORIZED => TrackerErrorCategory::Auth, + StatusCode::FORBIDDEN if body.to_ascii_lowercase().contains("rate limit") => { + TrackerErrorCategory::RateLimited + } StatusCode::FORBIDDEN => TrackerErrorCategory::PermissionDenied, StatusCode::NOT_FOUND => TrackerErrorCategory::NotFound, StatusCode::TOO_MANY_REQUESTS => TrackerErrorCategory::RateLimited, @@ -197,6 +200,11 @@ mod tests { body: "slow down".to_string(), retry_after: Some(Duration::from_secs(1)), }; + let github_rate_limited = LinearError::HttpStatus { + status: StatusCode::FORBIDDEN, + body: "GitHub API rate limit response".to_string(), + retry_after: Some(Duration::from_secs(2)), + }; assert_eq!(auth.category(), TrackerErrorCategory::Auth); assert_eq!( @@ -204,6 +212,14 @@ mod tests { TrackerErrorCategory::PermissionDenied ); assert_eq!(rate_limited.category(), TrackerErrorCategory::RateLimited); + assert_eq!( + github_rate_limited.category(), + TrackerErrorCategory::RateLimited + ); + assert_eq!( + github_rate_limited.retry_after(), + Some(Duration::from_secs(2)) + ); } #[test] diff --git a/crates/opensymphony-linear/src/graphql.rs b/crates/opensymphony-linear/src/graphql.rs index b2702c742..061682dc5 100644 --- a/crates/opensymphony-linear/src/graphql.rs +++ b/crates/opensymphony-linear/src/graphql.rs @@ -39,18 +39,23 @@ query IssuesByState($projectSlug: String!, $stateNames: [String!], $includeArchi title state { name + type } } projectMilestone { id name } - attachments { + attachments(first: 32, orderBy: updatedAt, filter: { url: { contains: "/pull/" } }) { nodes { title url sourceType } + pageInfo { + hasNextPage + endCursor + } } children(includeArchived: true, first: 100) { nodes { @@ -60,8 +65,13 @@ query IssuesByState($projectSlug: String!, $stateNames: [String!], $includeArchi title state { name + type } } + pageInfo { + hasNextPage + endCursor + } } labels(first: $labelFirst) { nodes { @@ -132,8 +142,13 @@ query IssueSummariesByState($projectSlug: String!, $stateNames: [String!], $incl title state { name + type } } + pageInfo { + hasNextPage + endCursor + } } inverseRelations(first: $relationFirst) { nodes { @@ -200,18 +215,23 @@ query ProjectIssues($projectSlug: String!, $includeArchived: Boolean!, $first: I title state { name + type } } projectMilestone { id name } - attachments { + attachments(first: 32, orderBy: updatedAt, filter: { url: { contains: "/pull/" } }) { nodes { title url sourceType } + pageInfo { + hasNextPage + endCursor + } } children(includeArchived: true, first: 100) { nodes { @@ -221,8 +241,13 @@ query ProjectIssues($projectSlug: String!, $includeArchived: Boolean!, $first: I title state { name + type } } + pageInfo { + hasNextPage + endCursor + } } labels(first: $labelFirst) { nodes { @@ -261,6 +286,30 @@ query ProjectIssues($projectSlug: String!, $includeArchived: Boolean!, $first: I } "#; +pub(super) const ISSUE_CHILDREN_QUERY: &str = r#" +query IssueChildren($issueId: String!, $first: Int!, $after: String) { + issue(id: $issueId) { + id + children(includeArchived: true, first: $first, after: $after) { + nodes { + id + identifier + url + title + state { + name + type + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +"#; + pub(super) const ISSUE_LABELS_QUERY: &str = r#" query IssueLabelsPage($issueId: String!, $first: Int!, $after: String) { issue(id: $issueId) { @@ -440,30 +489,40 @@ query IssueByIdentifier($identifier: String!, $relationFirst: Int!, $labelFirst: title state { name + type } } projectMilestone { id name } - attachments { + attachments(first: 32, orderBy: updatedAt, filter: { url: { contains: "/pull/" } }) { nodes { title url sourceType } + pageInfo { + hasNextPage + endCursor + } } - children(includeArchived: true, first: 100) { - nodes { - id - identifier - url - title - state { - name + children(includeArchived: true, first: 100) { + nodes { + id + identifier + url + title + state { + name + type + } + } + pageInfo { + hasNextPage + endCursor } } - } labels(first: $labelFirst) { nodes { name @@ -822,6 +881,14 @@ pub(super) struct IssueByIdentifierVariables { pub label_first: usize, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct IssueChildrenVariables { + pub issue_id: String, + pub first: usize, + pub after: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct IssueInverseRelationsVariables { @@ -927,6 +994,11 @@ pub(super) struct IssueByIdentifierData { pub issue: Option, } +#[derive(Debug, Deserialize)] +pub(super) struct IssueChildrenData { + pub issue: Option, +} + #[derive(Debug, Deserialize)] pub(super) struct IssueStatesByIdsData { pub issues: IssuesConnection, @@ -1125,7 +1197,10 @@ pub(super) struct LinearProjectMilestoneNode { #[derive(Debug, Deserialize, Default)] pub(super) struct LinearAttachmentConnection { + #[serde(default)] pub nodes: Vec, + #[serde(default, rename = "pageInfo")] + pub page_info: PageInfo, } #[derive(Debug, Deserialize)] @@ -1140,7 +1215,16 @@ pub(super) struct LinearAttachmentNode { #[derive(Debug, Deserialize, Default)] pub(super) struct LinearChildConnection { + #[serde(default)] pub nodes: Vec, + #[serde(default, rename = "pageInfo")] + pub page_info: PageInfo, +} + +#[derive(Debug, Deserialize)] +pub(super) struct LinearIssueChildrenNode { + pub id: String, + pub children: LinearChildConnection, } #[derive(Debug, Deserialize)] @@ -1157,6 +1241,9 @@ pub(super) struct LinearChildNode { #[derive(Debug, Deserialize)] pub(super) struct LinearIssueRefState { pub name: String, + #[serde(default)] + #[serde(rename = "type")] + pub kind: String, } #[derive(Debug, Deserialize, Default)] diff --git a/crates/opensymphony-linear/src/normalize.rs b/crates/opensymphony-linear/src/normalize.rs index 2be5667c9..5ed1d1443 100644 --- a/crates/opensymphony-linear/src/normalize.rs +++ b/crates/opensymphony-linear/src/normalize.rs @@ -2,6 +2,7 @@ use crate::opensymphony_domain::{ TrackerIssue, TrackerIssueBlocker, TrackerIssueRef, TrackerIssueState, TrackerIssueStateKind, TrackerIssueStateSnapshot, TrackerIssueSummary, TrackerProjectMilestone, }; +use url::Url; use super::error::LinearError; use super::graphql::{ @@ -11,6 +12,7 @@ use super::graphql::{ pub(super) fn normalize_issue(node: LinearIssueNode) -> Result { let state = normalize_state(node.state); + let pr_urls = normalize_pr_urls(node.attachments.nodes); Ok(TrackerIssue { id: node.id, identifier: node.identifier, @@ -21,7 +23,8 @@ pub(super) fn normalize_issue(node: LinearIssueNode) -> Result) -> Option { }) } -fn normalize_pr_url(attachments: Vec) -> Option { +fn normalize_pr_urls(attachments: Vec) -> Vec { attachments .into_iter() .filter(|attachment| { attachment .source_type .as_deref() - .map(|source_type| source_type.eq_ignore_ascii_case("github")) + .map(|source_type| { + source_type.eq_ignore_ascii_case("github") + || source_type.eq_ignore_ascii_case("api") + }) .unwrap_or(false) }) .map(|attachment| attachment.url) - .find(|url| is_canonical_github_pr_url(url)) + .filter(|url| is_canonical_github_pr_url(url)) + .collect() } fn is_canonical_github_pr_url(url: &str) -> bool { - let Some(path) = url.trim().strip_prefix("https://github.com/") else { + let Ok(url) = Url::parse(url.trim()) else { return false; }; - let mut parts = path.split('/'); + if url.scheme() != "https" || url.host_str().is_none() { + return false; + } + let mut parts = url.path_segments().into_iter().flatten(); let (Some(owner), Some(repo), Some("pull"), Some(number), None) = ( parts.next(), parts.next(), @@ -156,15 +166,24 @@ fn normalize_parent_id(parent: Option<&LinearParentNode>) -> Option { fn normalize_parent(parent: Option) -> Option { let parent = parent?; let identifier = parent.identifier?; + let state = parent.state.map(|state| { + ( + state.name, + TrackerIssueStateKind::from_tracker_type(state.kind), + ) + }); Some(TrackerIssueRef { id: parent.id, identifier, title: parent.title, url: parent.url, - state: parent - .state - .map(|state| state.name) + state: state + .as_ref() + .map(|state| state.0.clone()) .unwrap_or_else(|| "unknown".to_string()), + state_kind: state + .map(|state| state.1) + .unwrap_or_else(|| TrackerIssueStateKind::Unknown("unknown".to_owned())), }) } @@ -180,12 +199,16 @@ fn normalize_project_milestone( fn normalize_sub_issues(children: Vec) -> Vec { let mut sub_issues = children .into_iter() - .map(|child| TrackerIssueRef { - id: child.id, - identifier: child.identifier, - title: child.title, - url: child.url, - state: child.state.name, + .map(|child| { + let state = child.state; + TrackerIssueRef { + id: child.id, + identifier: child.identifier, + title: child.title, + url: child.url, + state: state.name, + state_kind: TrackerIssueStateKind::from_tracker_type(state.kind), + } }) .collect::>(); sub_issues.sort_by(|left, right| left.identifier.cmp(&right.identifier)); @@ -220,7 +243,8 @@ fn normalize_priority(priority: f64) -> Result, LinearError> { #[cfg(test)] mod tests { - use super::{is_canonical_github_pr_url, normalize_priority}; + use super::super::graphql::LinearAttachmentNode; + use super::{is_canonical_github_pr_url, normalize_pr_urls, normalize_priority}; #[test] fn priority_zero_becomes_none() { @@ -253,10 +277,13 @@ mod tests { } #[test] - fn github_pr_url_matching_requires_canonical_pull_path() { + fn github_pr_url_matching_supports_configured_enterprise_authorities() { assert!(is_canonical_github_pr_url( "https://github.com/kumanday/OpenSymphony/pull/155" )); + assert!(is_canonical_github_pr_url( + "https://github.enterprise.example/kumanday/OpenSymphony/pull/155" + )); assert!(!is_canonical_github_pr_url( "https://github.com/kumanday/OpenSymphony/wiki/pull/155" )); @@ -264,7 +291,37 @@ mod tests { "https://github.com/kumanday/OpenSymphony/pull/not-a-number" )); assert!(!is_canonical_github_pr_url( - "https://example.com/kumanday/OpenSymphony/pull/155" + "http://github.enterprise.example/kumanday/OpenSymphony/pull/155" )); } + + #[test] + fn canonical_pull_request_normalization_retains_all_attachments() { + let urls = normalize_pr_urls(vec![ + LinearAttachmentNode { + title: None, + url: "https://github.com/kumanday/OpenSymphony/pull/12".to_owned(), + source_type: Some("github".to_owned()), + }, + LinearAttachmentNode { + title: None, + url: "https://github.com/kumanday/OpenSymphony/pull/34".to_owned(), + source_type: Some("github".to_owned()), + }, + LinearAttachmentNode { + title: None, + url: "https://github.com/kumanday/OpenSymphony/pull/233".to_owned(), + source_type: Some("api".to_owned()), + }, + LinearAttachmentNode { + title: None, + url: "https://github.com/kumanday/OpenSymphony/issues/234".to_owned(), + source_type: Some("api".to_owned()), + }, + ]); + assert_eq!(urls.len(), 3); + assert_eq!(urls[0], "https://github.com/kumanday/OpenSymphony/pull/12"); + assert_eq!(urls[1], "https://github.com/kumanday/OpenSymphony/pull/34"); + assert_eq!(urls[2], "https://github.com/kumanday/OpenSymphony/pull/233"); + } } diff --git a/crates/opensymphony-linear/src/task_graph_cache.rs b/crates/opensymphony-linear/src/task_graph_cache.rs index ea45b33ba..32fcab98c 100644 --- a/crates/opensymphony-linear/src/task_graph_cache.rs +++ b/crates/opensymphony-linear/src/task_graph_cache.rs @@ -224,6 +224,7 @@ mod tests { state_kind: tracker_issue_state_kind_from_name(state), branch_name: None, pr_url: None, + pr_urls: Vec::new(), labels: vec!["backend".to_string()], project_id: None, project_slug: None, diff --git a/crates/opensymphony-linear/tests/linear_client.rs b/crates/opensymphony-linear/tests/linear_client.rs index 1b43fada0..6c45b63e6 100644 --- a/crates/opensymphony-linear/tests/linear_client.rs +++ b/crates/opensymphony-linear/tests/linear_client.rs @@ -149,10 +149,85 @@ async fn candidate_issues_normalize_fixture_payloads() { requests[0].body["query"] .as_str() .expect("query should be a string") - .contains("attachments {") + .contains("attachments(first: 32, orderBy: updatedAt, filter: { url: { contains: \"/pull/\" } }) {") ); } +#[tokio::test] +async fn issue_lookup_bounds_attachments_but_paginates_nested_children() { + let server = MockGraphqlServer::start(vec![ + QueuedResponse::json( + r#"{ + "data": {"issue": { + "id": "parent-id", + "identifier": "COE-1", + "url": "https://linear.app/example/issue/COE-1", + "title": "Parent", + "description": null, + "priority": 1, + "branchName": null, + "createdAt": "2026-08-12T00:00:00Z", + "updatedAt": "2026-08-12T00:00:00Z", + "state": {"id": "state-active", "name": "In Progress", "type": "started"}, + "project": null, + "parent": null, + "attachments": { + "nodes": [{"url":"https://github.com/example/repo/pull/1","sourceType":"github"}], + "pageInfo": {"hasNextPage": true, "endCursor": "attachment-cursor"} + }, + "children": { + "nodes": [{"id":"child-1","identifier":"COE-2","state":{"name":"Done"}}], + "pageInfo": {"hasNextPage": true, "endCursor": "child-cursor"} + }, + "labels": {"nodes": [], "pageInfo": {"hasNextPage": false, "endCursor": null}}, + "inverseRelations": {"nodes": [], "pageInfo": {"hasNextPage": false, "endCursor": null}} + }} + }"#, + ), + QueuedResponse::json( + r#"{ + "data": {"issue": { + "id": "parent-id", + "children": { + "nodes": [{"id":"child-2","identifier":"COE-3","state":{"name":"Done"}}], + "pageInfo": {"hasNextPage": false, "endCursor": null} + } + }} + }"#, + ), + ]) + .await; + let client = LinearClient::new(test_config(server.base_url())) + .expect("client configuration should be valid"); + + let issues = client + .issues_by_identifiers(&["COE-1"]) + .await + .expect("issue lookup should fetch all child pages"); + + assert_eq!(issues[0].sub_issues.len(), 2); + assert_eq!(issues[0].pr_urls.len(), 1); + let requests = server.recorded_requests().await; + assert_eq!( + requests.len(), + 2, + "attachment pagination must not issue requests" + ); + assert!( + requests[0].body["query"] + .as_str() + .expect("issue query should be a string") + .contains("attachments(first: 32, orderBy: updatedAt, filter: { url: { contains: \"/pull/\" } }) {") + ); + assert!( + requests[1].body["query"] + .as_str() + .expect("child page query should be a string") + .contains("query IssueChildren") + ); + assert_eq!(requests[1].body["variables"]["after"], "child-cursor"); +} + #[tokio::test] async fn candidate_issues_scan_every_configured_project() { let fixture = include_str!("fixtures/candidate_issues_page.json"); diff --git a/crates/opensymphony-openhands/src/session.rs b/crates/opensymphony-openhands/src/session.rs index 8e3b5d526..50b0fd8c9 100644 --- a/crates/opensymphony-openhands/src/session.rs +++ b/crates/opensymphony-openhands/src/session.rs @@ -156,6 +156,14 @@ pub trait WorkpadCommentSource: Send + Sync { pub trait IssueSessionObserver { fn on_launch(&mut self, _conversation: &ConversationMetadata) {} + fn on_launch_with_started_at( + &mut self, + conversation: &ConversationMetadata, + _started_at: Option, + ) { + self.on_launch(conversation); + } + fn on_runtime_event( &mut self, _observed_at: TimestampMs, @@ -1552,6 +1560,7 @@ impl IssueSessionRunner { ) .await?; run_manifest.status = RunStatus::Running; + run_manifest.started_at.get_or_insert_with(Utc::now); run_manifest.status_detail = Some(format!( "recovered {} prompt trigger for conversation {}", active_session.prompt_kind.as_str(), @@ -1561,10 +1570,11 @@ impl IssueSessionRunner { .write_run_manifest(workspace, run_manifest) .await?; } - observer.on_launch( + observer.on_launch_with_started_at( &active_session .manifest .to_domain_metadata(RuntimeStreamState::Ready), + run_manifest.started_at.map(timestamp_ms_from_datetime), ); let baseline_event_ids = pre_trigger_baseline_event_ids.unwrap_or_else(|| { recovery_baseline_event_ids( @@ -1626,10 +1636,11 @@ impl IssueSessionRunner { .execution_status() .is_some_and(turn_is_in_progress) { - observer.on_launch( + observer.on_launch_with_started_at( &active_session .manifest .to_domain_metadata(RuntimeStreamState::Ready), + run_manifest.started_at.map(timestamp_ms_from_datetime), ); launch_reported = true; } @@ -2336,10 +2347,11 @@ impl IssueSessionRunner { }) => { had_run_conflict = true; if !prepared_turn.launch_reported { - observer.on_launch( + observer.on_launch_with_started_at( &active_session .manifest .to_domain_metadata(RuntimeStreamState::Ready), + run_manifest.started_at.map(timestamp_ms_from_datetime), ); prepared_turn.launch_reported = true; } @@ -2420,6 +2432,7 @@ impl IssueSessionRunner { } run_manifest.status = RunStatus::Running; + run_manifest.started_at.get_or_insert_with(Utc::now); run_manifest.status_detail = Some(format!( "{} prompt sent to conversation {}", active_session.prompt_kind.as_str(), @@ -2447,10 +2460,11 @@ impl IssueSessionRunner { active_session.accumulate_tokens(); if !prepared_turn.launch_reported { - observer.on_launch( + observer.on_launch_with_started_at( &active_session .manifest .to_domain_metadata(RuntimeStreamState::Ready), + run_manifest.started_at.map(timestamp_ms_from_datetime), ); prepared_turn.launch_reported = true; } @@ -4934,6 +4948,7 @@ mod tests { }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: vec!["area:memory".to_string()], project_id: None, diff --git a/crates/opensymphony-openhands/tests/issue_session_runner.rs b/crates/opensymphony-openhands/tests/issue_session_runner.rs index bb52e2d0d..f4fe25fd5 100644 --- a/crates/opensymphony-openhands/tests/issue_session_runner.rs +++ b/crates/opensymphony-openhands/tests/issue_session_runner.rs @@ -51,6 +51,7 @@ fn sample_issue(identifier: &str) -> NormalizedIssue { }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: vec!["runtime".to_string()], project_id: None, diff --git a/crates/opensymphony-openhands/tests/live_local_suite.rs b/crates/opensymphony-openhands/tests/live_local_suite.rs index 1cc5f9872..a227140e7 100644 --- a/crates/opensymphony-openhands/tests/live_local_suite.rs +++ b/crates/opensymphony-openhands/tests/live_local_suite.rs @@ -752,6 +752,7 @@ fn sample_issue() -> NormalizedIssue { }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: vec!["live-suite".to_string()], project_id: None, diff --git a/crates/opensymphony-orchestrator/src/hierarchy.rs b/crates/opensymphony-orchestrator/src/hierarchy.rs new file mode 100644 index 000000000..6a7e145c1 --- /dev/null +++ b/crates/opensymphony-orchestrator/src/hierarchy.rs @@ -0,0 +1,2665 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::opensymphony_domain::{ + CanonicalRepositoryId, IssueId, IssueIdentifier, TimestampMs, TrackerIssue, TrackerIssueRef, + TrackerIssueStateKind, +}; +use serde::{Deserialize, Deserializer, Serialize}; +use thiserror::Error; + +pub const HIERARCHY_STATE_SCHEMA_VERSION: u32 = 1; +const MAX_INACTIVE_LEASE_HISTORY: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HierarchyChildEdge { + pub child_id: IssueId, + pub child_identifier: IssueIdentifier, + pub required: bool, +} + +impl From<&TrackerIssueRef> for HierarchyChildEdge { + fn from(child: &TrackerIssueRef) -> Self { + Self { + child_id: IssueId::new(child.id.clone()).expect("tracker child ids are validated"), + child_identifier: IssueIdentifier::new(child.identifier.clone()) + .expect("tracker child identifiers are validated"), + required: !matches!(&child.state_kind, TrackerIssueStateKind::Canceled), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum HierarchyBlockedReason { + HierarchyChanged, + MissingMergeEvidence, + MissingTargetCommit, + MissingCheckoutEvidence, + UnresolvedFailure(String), + StaleGeneration, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum HierarchyReconciliation { + Unchanged, + GenerationAdvanced { generation: u64 }, + BlockedForReplanning { generation: u64 }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HierarchySnapshot { + pub parent_id: IssueId, + pub generation: u64, + pub required_child_edges: Vec, + pub frozen: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_reason: Option, + /// The latest provider or orchestrator eligibility result. Unlike + /// `blocked_reason`, this is diagnostic state and does not require an + /// explicit hierarchy replan before the next eligibility attempt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eligibility_blocked_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dispatched_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dispatch_intent_generation: Option, + /// The generation of a worker that was already dispatched when a frozen + /// scope changed. Keep this fence until the scheduler observes the old + /// execution stopped; clearing `dispatched_generation` alone would let a + /// descendant mutate a checkout under the still-running worker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub in_flight_generation: Option, + /// Merge-result commits captured with a durable dispatch intent. Keep the + /// canonical repository identity beside each commit so the parent + /// integration layer can select the matching prepared worktree. + #[serde( + default, + deserialize_with = "deserialize_required_merge_commits", + skip_serializing_if = "Vec::is_empty" + )] + pub dispatch_required_merge_commits: Vec, +} + +impl HierarchySnapshot { + pub fn new(parent: &TrackerIssue) -> Self { + Self::new_with_canceled_states(parent, &[]) + } + + pub fn new_with_canceled_states(parent: &TrackerIssue, canceled_states: &[String]) -> Self { + let mut snapshot = Self { + parent_id: IssueId::new(parent.id.clone()).expect("tracker ids are validated"), + generation: 1, + required_child_edges: Vec::new(), + frozen: false, + blocked_reason: None, + eligibility_blocked_reason: None, + dispatched_generation: None, + dispatch_intent_generation: None, + in_flight_generation: None, + dispatch_required_merge_commits: Vec::new(), + }; + snapshot.required_child_edges = child_edges(&parent.sub_issues, canceled_states); + snapshot + } + + pub fn reconcile(&mut self, children: &[TrackerIssueRef]) -> HierarchyReconciliation { + self.reconcile_with_canceled_states(children, &[]) + } + + pub fn reconcile_with_canceled_states( + &mut self, + children: &[TrackerIssueRef], + canceled_states: &[String], + ) -> HierarchyReconciliation { + let next_edges = child_edges(children, canceled_states); + if next_edges == self.required_child_edges { + return HierarchyReconciliation::Unchanged; + } + + let current_scope = self + .required_child_edges + .iter() + .map(|edge| (&edge.child_id, edge.required)) + .collect::>(); + let next_scope = next_edges + .iter() + .map(|edge| (&edge.child_id, edge.required)) + .collect::>(); + if current_scope == next_scope { + self.required_child_edges = next_edges; + return HierarchyReconciliation::Unchanged; + } + + let previous_generation = self.generation; + self.generation = self.generation.saturating_add(1); + self.required_child_edges = next_edges; + self.eligibility_blocked_reason = None; + if self.frozen && self.dispatched_generation == Some(previous_generation) { + self.in_flight_generation.get_or_insert(previous_generation); + } + self.dispatched_generation = None; + self.dispatch_intent_generation = None; + self.dispatch_required_merge_commits.clear(); + if self.frozen { + self.blocked_reason = Some(HierarchyBlockedReason::HierarchyChanged); + HierarchyReconciliation::BlockedForReplanning { + generation: self.generation, + } + } else { + self.blocked_reason = None; + HierarchyReconciliation::GenerationAdvanced { + generation: self.generation, + } + } + } + + pub fn freeze(&mut self) -> Result<(), HierarchyBlockedReason> { + if self.blocked_reason.is_some() { + return Err(self.blocked_reason.clone().expect("checked above")); + } + self.frozen = true; + Ok(()) + } + + pub fn accepts_event(&self, generation: u64) -> bool { + generation == self.generation && self.blocked_reason.is_none() + } + + pub fn replan(&mut self) { + self.frozen = false; + self.blocked_reason = None; + self.eligibility_blocked_reason = None; + self.dispatched_generation = None; + self.dispatch_intent_generation = None; + self.dispatch_required_merge_commits.clear(); + self.in_flight_generation = None; + } + + pub fn mark_dispatch_intent(&mut self) { + self.dispatch_intent_generation = Some(self.generation); + } + + pub fn clear_dispatch_intent(&mut self) { + self.dispatch_intent_generation = None; + self.dispatch_required_merge_commits.clear(); + } + + pub fn dispatch_intended(&self) -> bool { + self.dispatch_intent_generation == Some(self.generation) + } + + pub fn restore_in_flight_dispatch(&mut self) -> bool { + let Some(generation) = self.dispatch_intent_generation else { + return false; + }; + self.restore_in_flight_dispatch_for_generation(generation) + } + + /// Restore the execution fence for a recovered run before tracker + /// reconciliation. A normally launched run has already replaced its + /// dispatch intent with the durable claim, so recovery must accept either + /// marker as evidence that the generation is in flight. + pub fn restore_recovered_dispatch_fence(&mut self) -> bool { + let Some(generation) = self + .dispatch_intent_generation + .or(self.dispatched_generation) + else { + return false; + }; + self.restore_in_flight_dispatch_for_generation(generation) + } + + fn restore_in_flight_dispatch_for_generation(&mut self, generation: u64) -> bool { + if self.in_flight_generation == Some(generation) { + return false; + } + self.in_flight_generation = Some(generation); + true + } + + pub fn mark_dispatched(&mut self) { + self.dispatched_generation = Some(self.generation); + self.dispatch_intent_generation = None; + self.dispatch_required_merge_commits.clear(); + self.in_flight_generation = None; + } + + pub fn dispatch_claimed(&self) -> bool { + self.dispatched_generation == Some(self.generation) + } + + pub fn has_dispatched_execution_fence(&self) -> bool { + self.dispatch_claimed() || self.in_flight_generation.is_some() + } + + pub fn has_in_flight_dispatch(&self) -> bool { + self.in_flight_generation.is_some() + } + + pub fn clear_in_flight_dispatch(&mut self) -> bool { + self.in_flight_generation.take().is_some() + } +} + +fn child_edges( + children: &[TrackerIssueRef], + canceled_states: &[String], +) -> Vec { + let mut edges = children + .iter() + .map(|child| HierarchyChildEdge { + child_id: IssueId::new(child.id.clone()).expect("tracker child ids are validated"), + child_identifier: IssueIdentifier::new(child.identifier.clone()) + .expect("tracker child identifiers are validated"), + required: !matches!(&child.state_kind, TrackerIssueStateKind::Canceled) + && !canceled_states.iter().any(|configured_state| { + configured_state.to_ascii_lowercase().contains("cancel") + && configured_state + .trim() + .eq_ignore_ascii_case(child.state.trim()) + }), + }) + .collect::>(); + edges.sort_by(|left, right| left.child_id.cmp(&right.child_id)); + edges +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct LeaseResource { + pub issue_id: IssueId, + pub repository_id: CanonicalRepositoryId, + pub checkout_generation: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct LeaseOwner { + pub id: String, + pub kind: String, +} + +impl LeaseOwner { + pub fn leaf_worker(issue_id: &IssueId) -> Self { + Self { + id: format!("leaf-worker:{issue_id}"), + kind: "leaf_worker".to_owned(), + } + } + + pub fn ancestor(parent_id: &IssueId) -> Self { + Self { + id: format!("ancestor-integration:{parent_id}"), + kind: "ancestor_integration".to_owned(), + } + } + + pub fn review(child_id: &IssueId) -> Self { + Self { + id: format!("review:{child_id}"), + kind: "review".to_owned(), + } + } + + pub fn review_for_parent(parent_id: &IssueId, child_id: &IssueId) -> Self { + Self { + id: format!("review:{parent_id}:{child_id}"), + kind: "review".to_owned(), + } + } + + pub fn repair(parent_id: &IssueId) -> Self { + Self { + id: format!("repair:{parent_id}"), + kind: "repair".to_owned(), + } + } + + pub fn diagnostic(operator_id: impl Into) -> Self { + Self { + id: format!("diagnostic:{}", operator_id.into()), + kind: "diagnostic_hold".to_owned(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LeaseKind { + LeafWorker, + Review, + AncestorIntegration, + Repair, + DiagnosticHold, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeaseRecord { + pub kind: LeaseKind, + pub resource: LeaseResource, + pub owner: LeaseOwner, + pub hierarchy_generation: u64, + pub acquired_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub released_at: Option, +} + +impl LeaseRecord { + pub fn active(&self) -> bool { + self.active_at(current_epoch_millis()) + } + + pub fn active_at(&self, now: u64) -> bool { + self.released_at.is_none() && self.expires_at.is_none_or(|expires_at| expires_at > now) + } +} + +fn current_epoch_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + duration.as_millis().min(u128::from(u64::MAX)) as u64 + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum LeaseError { + #[error("lease resource {resource:?} is already owned by {owner}")] + Conflict { + resource: LeaseResource, + owner: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderEvidenceBoundary { + pub issue_id: IssueId, + pub evidence_at: TimestampMs, +} + +/// A provider-reported merge result, retained with the repository it belongs +/// to. Parent integration work must never verify a flattened commit list +/// against an arbitrary repository root. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct RequiredMergeCommit { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_id: Option, + pub commit: String, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum SerializedRequiredMergeCommit { + Detailed(RequiredMergeCommit), + Legacy(String), +} + +fn deserialize_required_merge_commits<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Vec::::deserialize(deserializer).map(|commits| { + commits + .into_iter() + .map(|commit| match commit { + SerializedRequiredMergeCommit::Detailed(commit) => commit, + SerializedRequiredMergeCommit::Legacy(commit) => RequiredMergeCommit { + repository_id: None, + commit, + }, + }) + .collect() + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChildEligibilityEvidence { + pub child_id: IssueId, + pub hierarchy_generation: u64, + pub orchestrator_terminal: bool, + pub provider_merge_confirmed: bool, + /// Repository-neutral descendants with only canceled leaves have no + /// provider merge to prove. Keep that resolved edge distinct from a + /// missing merge commit on a repository-backed child. + #[serde(default = "default_merge_required", skip_serializing_if = "is_true")] + pub merge_required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_result_commit: Option, + /// Direct or descendant merge commits recorded by the provider. The + /// singular field remains the compatibility projection for older state. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub merge_result_commits: Vec, + /// Repository-bound merge results for nested parents and multi-repository + /// children. The string list above remains a compatibility projection. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub merge_result_commits_by_repository: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_repository_id: Option, + /// Repository identities for every descendant merge commit. The + /// singular field remains the compatibility projection for direct child + /// evidence, while nested parents may contribute several repositories. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub merge_repository_ids: Vec, + /// Provider evidence must be newer than the child run that produced the + /// retained checkout. This prevents a reactivated child from reusing a + /// previously merged PR on the same branch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_evidence_at: Option, + /// Provider evidence for every repository-backed descendant leaf. The + /// aggregate timestamp remains for compatibility, while nested parents + /// need each leaf boundary to fence reactivated descendants. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub provider_evidence_by_issue: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + /// Repository-neutral child parents contribute the retained descendant + /// generations held by their own ancestor edge. Keep the legacy singular + /// field for direct leaf evidence and use this collection for subtrees. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub resources: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unresolved_failure: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentEligibilityEvidence { + pub hierarchy_generation: u64, + pub children: Vec, +} + +impl ParentEligibilityEvidence { + pub fn tracker_only(parent: &TrackerIssue, generation: u64) -> Self { + Self { + hierarchy_generation: generation, + children: parent + .sub_issues + .iter() + .map(|child| ChildEligibilityEvidence { + child_id: IssueId::new(child.id.clone()) + .expect("tracker child ids are validated"), + hierarchy_generation: generation, + orchestrator_terminal: false, + provider_merge_confirmed: false, + merge_required: true, + merge_result_commit: None, + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: Vec::new(), + merge_repository_id: None, + merge_repository_ids: Vec::new(), + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: None, + resources: Vec::new(), + unresolved_failure: None, + }) + .collect(), + } + } + + pub fn tracker_only_for_snapshot(snapshot: &HierarchySnapshot) -> Self { + Self { + hierarchy_generation: snapshot.generation, + children: snapshot + .required_child_edges + .iter() + .map(|edge| ChildEligibilityEvidence { + child_id: edge.child_id.clone(), + hierarchy_generation: snapshot.generation, + orchestrator_terminal: false, + provider_merge_confirmed: false, + merge_required: true, + merge_result_commit: None, + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: Vec::new(), + merge_repository_id: None, + merge_repository_ids: Vec::new(), + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: None, + resources: Vec::new(), + unresolved_failure: None, + }) + .collect(), + } + } + + pub fn eligible_for(&self, snapshot: &HierarchySnapshot) -> Result<(), HierarchyBlockedReason> { + if snapshot.blocked_reason.is_some() || self.hierarchy_generation != snapshot.generation { + return Err(HierarchyBlockedReason::StaleGeneration); + } + let expected = snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + .collect::>(); + let actual = self + .children + .iter() + .map(|child| child.child_id.clone()) + .collect::>(); + if expected != actual { + return Err(HierarchyBlockedReason::HierarchyChanged); + } + for child in &self.children { + if child.hierarchy_generation != snapshot.generation { + return Err(HierarchyBlockedReason::StaleGeneration); + } + if !child.orchestrator_terminal { + return Err(HierarchyBlockedReason::UnresolvedFailure( + "child orchestrator outcome is not terminal".to_owned(), + )); + } + if let Some(failure) = &child.unresolved_failure { + return Err(HierarchyBlockedReason::UnresolvedFailure(failure.clone())); + } + if !child.merge_required { + if child.merge_result_commit.is_some() + || !child.merge_result_commits.is_empty() + || child.merge_repository_id.is_some() + || !child.merge_repository_ids.is_empty() + || child.resource.is_some() + || !child.resources.is_empty() + { + return Err(HierarchyBlockedReason::MissingCheckoutEvidence); + } + continue; + } + if !child.provider_merge_confirmed { + return Err(HierarchyBlockedReason::MissingMergeEvidence); + } + if child + .merge_result_commit + .as_deref() + .is_none_or(|commit| commit.trim().is_empty()) + && child.merge_result_commits.is_empty() + { + return Err(HierarchyBlockedReason::MissingTargetCommit); + } + let resources = child.resources(); + if child + .merge_result_commits_by_repository + .iter() + .any(|commit| { + commit.repository_id.as_ref().is_none_or(|repository_id| { + !resources + .iter() + .any(|resource| &resource.repository_id == repository_id) + }) + }) + { + return Err(HierarchyBlockedReason::MissingCheckoutEvidence); + } + let merge_repository_ids = if child.merge_repository_ids.is_empty() { + child.merge_repository_id.iter().collect::>() + } else { + child.merge_repository_ids.iter().collect::>() + }; + if merge_repository_ids.iter().any(|merge_repository| { + !resources + .iter() + .any(|resource| &resource.repository_id == *merge_repository) + }) { + return Err(HierarchyBlockedReason::MissingCheckoutEvidence); + } + if resources.is_empty() { + return Err(HierarchyBlockedReason::MissingCheckoutEvidence); + } + if child.resources.is_empty() + && child + .resource + .as_ref() + .is_some_and(|resource| resource.issue_id != child.child_id) + { + return Err(HierarchyBlockedReason::MissingCheckoutEvidence); + } + } + Ok(()) + } + + pub fn integration_leases(&self, parent_id: &IssueId, now: u64) -> Vec { + let owner = LeaseOwner::ancestor(parent_id); + self.children + .iter() + .flat_map(|child| child.resources().into_iter().cloned()) + .map(|resource| LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: owner.clone(), + hierarchy_generation: self.hierarchy_generation, + acquired_at: now, + expires_at: None, + released_at: None, + }) + .collect() + } +} + +impl ChildEligibilityEvidence { + pub fn resources(&self) -> Vec<&LeaseResource> { + if self.resources.is_empty() { + self.resource.iter().collect() + } else { + self.resources.iter().collect() + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DurableOrchestratorState { + pub schema_version: u32, + #[serde(default)] + pub hierarchy: BTreeMap, + #[serde(default)] + pub leases: Vec, + #[serde(default)] + pub run_hierarchy_generations: BTreeMap, + /// Durable run-start boundaries used to fence provider evidence after + /// recovery, when the in-memory execution history is not yet hydrated. + #[serde(default)] + pub run_started_at_by_issue: BTreeMap, + /// Successful orchestrator outcomes that remain relevant to higher + /// parents after the completed workspace has been cleaned up. + #[serde(default)] + pub terminal_orchestrator_issues: BTreeSet, +} + +impl Default for DurableOrchestratorState { + fn default() -> Self { + Self { + schema_version: HIERARCHY_STATE_SCHEMA_VERSION, + hierarchy: BTreeMap::new(), + leases: Vec::new(), + run_hierarchy_generations: BTreeMap::new(), + run_started_at_by_issue: BTreeMap::new(), + terminal_orchestrator_issues: BTreeSet::new(), + } + } +} + +impl DurableOrchestratorState { + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != HIERARCHY_STATE_SCHEMA_VERSION { + return Err(format!( + "unsupported durable hierarchy schema version {}", + self.schema_version + )); + } + if self.hierarchy.iter().any(|(parent_id, snapshot)| { + parent_id != &snapshot.parent_id || snapshot.generation == 0 + }) { + return Err("durable hierarchy snapshot has invalid identity or generation".to_owned()); + } + Ok(()) + } + + pub fn acquire_leases(&mut self, records: Vec) -> Result<(), LeaseError> { + let mut next = self.leases.clone(); + for requested in records { + if let Some(existing) = next.iter_mut().find(|existing| { + existing.active() + && existing.kind == requested.kind + && existing.resource == requested.resource + && existing.owner == requested.owner + }) { + *existing = requested; + continue; + } + if let Some(existing) = next.iter().find(|existing| { + existing.active() + && existing.kind == requested.kind + && existing.resource == requested.resource + && existing.owner != requested.owner + && matches!(requested.kind, LeaseKind::LeafWorker) + }) { + return Err(LeaseError::Conflict { + resource: requested.resource, + owner: existing.owner.id.clone(), + }); + } + next.push(requested); + } + self.leases = next; + self.compact_lease_history(); + Ok(()) + } + + pub fn acquire_required_and_release_leaf( + &mut self, + required: Vec, + leaf_resources: &[LeaseResource], + released_at: u64, + ) -> Result<(), LeaseError> { + let mut next = self.clone(); + next.acquire_leases(required)?; + let leaf_owner = LeaseOwner::leaf_worker; + for resource in leaf_resources { + for lease in next.leases.iter_mut().filter(|lease| { + lease.active() + && lease.kind == LeaseKind::LeafWorker + && lease.resource == *resource + && lease.owner == leaf_owner(&resource.issue_id) + }) { + lease.released_at = Some(released_at); + } + } + *self = next; + self.compact_lease_history(); + Ok(()) + } + + pub fn release_owner(&mut self, owner: &LeaseOwner, released_at: u64) { + for lease in &mut self.leases { + if lease.active() && lease.owner == *owner { + lease.released_at = Some(released_at); + } + } + self.compact_lease_history(); + } + + pub fn release_resource_leases(&mut self, resource: &LeaseResource, released_at: u64) -> bool { + let mut released = false; + for lease in &mut self.leases { + if lease.active() && lease.resource == *resource { + lease.released_at = Some(released_at); + released = true; + } + } + self.compact_lease_history(); + released + } + + pub fn release_parent_leases(&mut self, parent_id: &IssueId, released_at: u64) -> bool { + self.release_parent_leases_inner(parent_id, released_at, true) + } + + pub fn release_parent_leases_preserving_ancestor( + &mut self, + parent_id: &IssueId, + released_at: u64, + ) -> bool { + self.release_parent_leases_inner(parent_id, released_at, false) + } + + fn release_parent_leases_inner( + &mut self, + parent_id: &IssueId, + released_at: u64, + release_ancestor: bool, + ) -> bool { + let ancestor_owner = LeaseOwner::ancestor(parent_id); + let review_prefix = format!("review:{parent_id}:"); + let mut released = false; + for lease in &mut self.leases { + if !lease.active() { + continue; + } + let owned_by_parent = (release_ancestor && lease.owner == ancestor_owner) + || (lease.kind == LeaseKind::Review + && lease.owner.kind == "review" + && lease.owner.id.starts_with(&review_prefix)); + if owned_by_parent { + lease.released_at = Some(released_at); + released = true; + } + } + self.compact_lease_history(); + released + } + + pub fn descendant_resources_for(&self, parent_id: &IssueId) -> Vec { + let owner = LeaseOwner::ancestor(parent_id); + let current_subtree = self.current_subtree_issue_ids(parent_id); + let current_generation = self + .hierarchy + .get(parent_id) + .map(|snapshot| snapshot.generation) + .or_else(|| self.run_hierarchy_generations.get(parent_id).copied()); + self.resources_for_ancestor_owner(&owner, current_generation, current_subtree) + } + + pub fn ancestor_resources_for_child( + &self, + parent_id: &IssueId, + child_id: &IssueId, + ) -> Vec { + let Some(snapshot) = self.hierarchy.get(parent_id) else { + return Vec::new(); + }; + if !snapshot + .required_child_edges + .iter() + .any(|edge| edge.required && edge.child_id == *child_id) + { + return Vec::new(); + } + self.resources_for_ancestor_owner( + &LeaseOwner::ancestor(parent_id), + Some(snapshot.generation), + Some(self.subtree_issue_ids(child_id)), + ) + } + + fn resources_for_ancestor_owner( + &self, + owner: &LeaseOwner, + current_generation: Option, + current_subtree: Option>, + ) -> Vec { + let mut resources = BTreeMap::::new(); + for lease in &self.leases { + if lease.active() + && lease.kind == LeaseKind::AncestorIntegration + && &lease.owner == owner + && current_generation + .is_none_or(|generation| lease.hierarchy_generation == generation) + && current_subtree + .as_ref() + .is_some_and(|subtree| subtree.contains(&lease.resource.issue_id)) + { + resources + .entry(lease.resource.clone()) + .and_modify(|acquired_at| *acquired_at = (*acquired_at).max(lease.acquired_at)) + .or_insert(lease.acquired_at); + } + } + resources.into_keys().collect() + } + + pub fn prune_obsolete_run_boundaries( + &mut self, + retained_issue_ids: &BTreeSet, + ) -> bool { + let mut live_issue_ids = retained_issue_ids.clone(); + live_issue_ids.extend(self.hierarchy.keys().cloned()); + live_issue_ids.extend(self.hierarchy.values().flat_map(|snapshot| { + snapshot + .required_child_edges + .iter() + .map(|edge| edge.child_id.clone()) + })); + live_issue_ids.extend( + self.leases + .iter() + .filter(|lease| lease.active()) + .map(|lease| lease.resource.issue_id.clone()), + ); + + let previous_run_hierarchy_len = self.run_hierarchy_generations.len(); + let previous_run_started_len = self.run_started_at_by_issue.len(); + let previous_terminal_outcomes_len = self.terminal_orchestrator_issues.len(); + self.run_hierarchy_generations + .retain(|issue_id, _| live_issue_ids.contains(issue_id)); + self.run_started_at_by_issue + .retain(|issue_id, _| live_issue_ids.contains(issue_id)); + self.terminal_orchestrator_issues + .retain(|issue_id| live_issue_ids.contains(issue_id)); + previous_run_hierarchy_len != self.run_hierarchy_generations.len() + || previous_run_started_len != self.run_started_at_by_issue.len() + || previous_terminal_outcomes_len != self.terminal_orchestrator_issues.len() + } + + fn current_subtree_issue_ids(&self, parent_id: &IssueId) -> Option> { + if !self.hierarchy.contains_key(parent_id) { + return None; + } + Some(self.subtree_issue_ids(parent_id)) + } + + fn subtree_issue_ids(&self, parent_id: &IssueId) -> BTreeSet { + let mut subtree = BTreeSet::from([parent_id.clone()]); + let mut pending = vec![parent_id.clone()]; + while let Some(issue_id) = pending.pop() { + let Some(snapshot) = self.hierarchy.get(&issue_id) else { + continue; + }; + for child_id in snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + { + if subtree.insert(child_id.clone()) { + pending.push(child_id); + } + } + } + subtree + } + + pub fn release_obsolete_leaf_leases( + &mut self, + removed_child_ids: &[IssueId], + released_at: u64, + ) { + let retained_child_ids = self + .hierarchy + .values() + .flat_map(|snapshot| snapshot.required_child_edges.iter()) + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + .collect::>(); + for lease in &mut self.leases { + if lease.active() + && lease.kind == LeaseKind::LeafWorker + && removed_child_ids.contains(&lease.resource.issue_id) + && !retained_child_ids.contains(&lease.resource.issue_id) + { + lease.released_at = Some(released_at); + } + } + self.compact_lease_history(); + } + + // Preserve the complete required subtree when any of its roots is still + // needed by another parent, including parents not materialized locally yet. + fn retained_subtree_issue_ids( + &self, + parent_id: &IssueId, + subtree: &BTreeSet, + reachable_child_edges: Option<&BTreeSet<(IssueId, IssueId)>>, + ) -> BTreeSet { + let mut children = BTreeMap::<&IssueId, BTreeSet<&IssueId>>::new(); + for (parent, child) in self + .hierarchy + .iter() + .flat_map(|(parent, snapshot)| { + snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(move |edge| (parent, &edge.child_id)) + }) + .chain( + reachable_child_edges + .into_iter() + .flatten() + .map(|(parent, child)| (parent, child)), + ) + { + if parent != parent_id { + children.entry(parent).or_default().insert(child); + } + } + let mut pending = children + .iter() + .filter(|(parent, _)| !subtree.contains(*parent)) + .flat_map(|(_, children)| children.iter().copied()) + .collect::>(); + let mut retained = BTreeSet::new(); + while let Some(issue_id) = pending.pop() { + if subtree.contains(issue_id) + && retained.insert(issue_id.clone()) + && let Some(descendants) = children.get(issue_id) + { + pending.extend(descendants.iter().copied()); + } + } + retained + } + + /// Release evidence owned by child subtrees removed from a parent's + /// frozen scope. Owners are intentionally scoped to the removed subtree; + /// leases held by the still-retained parent or siblings remain active. + pub fn release_removed_subtree_leases( + &mut self, + parent_id: &IssueId, + removed_child_ids: &[IssueId], + reachable_child_edges: Option<&BTreeSet<(IssueId, IssueId)>>, + released_at: u64, + ) -> bool { + let mut subtree = BTreeSet::new(); + let mut pending = removed_child_ids.to_vec(); + while let Some(issue_id) = pending.pop() { + if !subtree.insert(issue_id.clone()) { + continue; + } + if let Some(snapshot) = self.hierarchy.get(&issue_id) { + pending.extend( + snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()), + ); + } + } + if subtree.is_empty() { + return false; + } + + let retained_child_ids = + self.retained_subtree_issue_ids(parent_id, &subtree, reachable_child_edges); + + let mut review_prefixes = removed_child_ids + .iter() + .map(|child_id| format!("review:{parent_id}:{child_id}")) + .collect::>(); + review_prefixes.extend( + self.hierarchy + .keys() + .filter(|issue_id| { + subtree.contains(*issue_id) && !retained_child_ids.contains(*issue_id) + }) + .map(|issue_id| format!("review:{issue_id}")), + ); + + let mut released = false; + for lease in &mut self.leases { + if !lease.active() { + continue; + } + let owned_by_removed_subtree = match lease.kind { + LeaseKind::LeafWorker => { + subtree.contains(&lease.resource.issue_id) + && !retained_child_ids.contains(&lease.resource.issue_id) + } + LeaseKind::AncestorIntegration => subtree.iter().any(|issue_id| { + lease.owner == LeaseOwner::ancestor(issue_id) + && !retained_child_ids.contains(issue_id) + }), + LeaseKind::Review => review_prefixes.iter().any(|prefix| { + lease.owner.id == *prefix || lease.owner.id.starts_with(&format!("{prefix}:")) + }), + LeaseKind::Repair | LeaseKind::DiagnosticHold => false, + }; + if owned_by_removed_subtree { + lease.released_at = Some(released_at); + released = true; + } + } + self.compact_lease_history(); + released + } + + pub fn rebind_leaf_leases(&mut self, child_ids: &BTreeSet, generation: u64) { + for lease in &mut self.leases { + if lease.active() + && lease.kind == LeaseKind::LeafWorker + && child_ids.contains(&lease.resource.issue_id) + { + lease.hierarchy_generation = generation; + } + } + } + + pub fn release_ancestor_leases_for_children( + &mut self, + child_ids: &[IssueId], + released_at: u64, + ) { + for lease in &mut self.leases { + if lease.active() + && lease.kind == LeaseKind::AncestorIntegration + && child_ids + .iter() + .any(|child_id| lease.owner == LeaseOwner::ancestor(child_id)) + { + lease.released_at = Some(released_at); + } + } + self.compact_lease_history(); + } + + pub fn release_subtree_evidence_for_undispatched_parent( + &mut self, + parent_id: &IssueId, + released_at: u64, + ) -> bool { + self.release_subtree_evidence_for_undispatched_parent_with_reachability( + parent_id, + None, + released_at, + ) + } + + pub fn release_subtree_evidence_for_undispatched_parent_with_reachability( + &mut self, + parent_id: &IssueId, + reachable_child_edges: Option<&BTreeSet<(IssueId, IssueId)>>, + released_at: u64, + ) -> bool { + let mut subtree = BTreeSet::new(); + let mut pending = self + .hierarchy + .get(parent_id) + .into_iter() + .flat_map(|snapshot| snapshot.required_child_edges.iter()) + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + .collect::>(); + while let Some(issue_id) = pending.pop() { + if !subtree.insert(issue_id.clone()) { + continue; + } + if let Some(snapshot) = self.hierarchy.get(&issue_id) { + pending.extend( + snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()), + ); + } + } + + let retained_child_ids = + self.retained_subtree_issue_ids(parent_id, &subtree, reachable_child_edges); + + let mut review_prefixes = vec![format!("review:{parent_id}:")]; + review_prefixes.extend( + self.hierarchy + .keys() + .filter(|issue_id| { + subtree.contains(*issue_id) && !retained_child_ids.contains(*issue_id) + }) + .map(|issue_id| format!("review:{issue_id}:")) + .collect::>(), + ); + let parent_ancestor_owner = LeaseOwner::ancestor(parent_id); + let mut released = false; + for lease in &mut self.leases { + if !lease.active() { + continue; + } + let owned_by_subtree = match lease.kind { + LeaseKind::LeafWorker => { + subtree.contains(&lease.resource.issue_id) + && !retained_child_ids.contains(&lease.resource.issue_id) + } + LeaseKind::AncestorIntegration => { + lease.owner == parent_ancestor_owner + || subtree.iter().any(|issue_id| { + lease.owner == LeaseOwner::ancestor(issue_id) + && !retained_child_ids.contains(issue_id) + }) + } + LeaseKind::Review => review_prefixes + .iter() + .any(|prefix| lease.owner.id.starts_with(prefix)), + LeaseKind::Repair | LeaseKind::DiagnosticHold => false, + }; + if owned_by_subtree { + lease.released_at = Some(released_at); + released = true; + } + } + self.compact_lease_history(); + released + } + + pub fn rebind_ancestor_leases(&mut self, parent_id: &IssueId, generation: u64) { + let owner = LeaseOwner::ancestor(parent_id); + for lease in &mut self.leases { + if lease.active() + && lease.kind == LeaseKind::AncestorIntegration + && lease.owner == owner + { + lease.hierarchy_generation = generation; + } + } + } + + pub fn release_leaf_leases_for_parent(&mut self, parent_id: &IssueId, released_at: u64) { + let ancestor_owner = LeaseOwner::ancestor(parent_id); + let resources = self + .leases + .iter() + .filter(|lease| { + lease.active() + && lease.kind == LeaseKind::AncestorIntegration + && lease.owner == ancestor_owner + }) + .map(|lease| lease.resource.clone()) + .collect::>(); + for lease in &mut self.leases { + if lease.active() + && lease.kind == LeaseKind::LeafWorker + && resources.iter().any(|resource| resource == &lease.resource) + && lease.owner == LeaseOwner::leaf_worker(&lease.resource.issue_id) + { + lease.released_at = Some(released_at); + } + } + self.compact_lease_history(); + } + + fn compact_lease_history(&mut self) { + let mut compacted = Vec::new(); + let mut seen_inactive = BTreeSet::new(); + for lease in self.leases.drain(..).rev() { + let key = ( + lease.kind.clone(), + lease.resource.clone(), + lease.owner.clone(), + ); + if lease.active() || seen_inactive.insert(key) { + compacted.push(lease); + } + } + compacted.reverse(); + + let inactive_count = compacted.iter().filter(|lease| !lease.active()).count(); + if inactive_count > MAX_INACTIVE_LEASE_HISTORY { + let drop_count = inactive_count - MAX_INACTIVE_LEASE_HISTORY; + let mut candidates = compacted + .iter() + .enumerate() + .filter(|(_, lease)| !lease.active()) + .map(|(index, lease)| { + ( + index, + lease + .released_at + .or(lease.expires_at) + .unwrap_or(lease.acquired_at), + ) + }) + .collect::>(); + candidates.sort_by_key(|(_, timestamp)| *timestamp); + let drop_indices = candidates + .into_iter() + .take(drop_count) + .map(|(index, _)| index) + .collect::>(); + self.leases = compacted + .into_iter() + .enumerate() + .filter(|(index, _)| !drop_indices.contains(index)) + .map(|(_, lease)| lease) + .collect(); + } else { + self.leases = compacted; + } + } + + pub fn active_for(&self, resource: &LeaseResource) -> bool { + self.leases + .iter() + .any(|lease| lease.active() && lease.resource == *resource) + } + + pub fn has_ancestor_edge(&self, child_id: &IssueId) -> bool { + self.hierarchy.values().any(|snapshot| { + snapshot + .required_child_edges + .iter() + .any(|edge| edge.required && &edge.child_id == child_id) + }) + } + + pub fn has_dispatched_ancestor(&self, child_id: &IssueId) -> bool { + self.hierarchy.values().any(|snapshot| { + snapshot.dispatch_claimed() + && snapshot + .required_child_edges + .iter() + .any(|edge| edge.required && &edge.child_id == child_id) + }) + } + + pub fn has_active_dispatched_ancestor(&self, child_id: &IssueId) -> bool { + let mut descendants = vec![child_id.clone()]; + let mut visited = BTreeSet::from([child_id.clone()]); + while let Some(descendant_id) = descendants.pop() { + for (parent_id, snapshot) in &self.hierarchy { + if !snapshot + .required_child_edges + .iter() + .any(|edge| edge.required && edge.child_id == descendant_id) + { + continue; + } + if snapshot.has_dispatched_execution_fence() + && self.leases.iter().any(|lease| { + lease.active() + && lease.kind == LeaseKind::AncestorIntegration + && lease.owner == LeaseOwner::ancestor(parent_id) + }) + { + return true; + } + if visited.insert(parent_id.clone()) { + descendants.push(parent_id.clone()); + } + } + } + false + } +} + +fn default_merge_required() -> bool { + true +} + +fn is_true(value: &bool) -> bool { + *value +} + +#[cfg(test)] +mod tests { + use super::*; + + fn child(id: &str, state: &str) -> TrackerIssueRef { + TrackerIssueRef { + id: id.to_owned(), + identifier: id.to_owned(), + title: None, + url: None, + state: state.to_owned(), + state_kind: TrackerIssueStateKind::from_tracker_name(state), + } + } + + fn parent(children: Vec) -> TrackerIssue { + TrackerIssue { + id: "parent".to_owned(), + identifier: "COE-1".to_owned(), + url: String::new(), + title: String::new(), + description: None, + priority: None, + state: "In Progress".to_owned(), + state_kind: TrackerIssueStateKind::Started, + branch_name: None, + pr_url: None, + pr_urls: Vec::new(), + labels: Vec::new(), + project_id: None, + project_slug: None, + project_name: None, + parent_id: None, + parent: None, + project_milestone: None, + blocked_by: Vec::new(), + sub_issues: children, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } + + #[test] + fn mutation_after_freeze_blocks_and_stale_events_are_rejected() { + let mut snapshot = HierarchySnapshot::new(&parent(vec![child("child-a", "Done")])); + assert_eq!(snapshot.generation, 1); + snapshot.freeze().expect("initial scope should freeze"); + + assert_eq!( + snapshot.reconcile(&[child("child-b", "Done")]), + HierarchyReconciliation::BlockedForReplanning { generation: 2 } + ); + assert!(!snapshot.accepts_event(1)); + assert!(!snapshot.accepts_event(2)); + snapshot.replan(); + assert!(snapshot.accepts_event(2)); + } + + #[test] + fn dispatch_merge_commit_state_preserves_repository_binding_and_reads_legacy_strings() { + let mut snapshot = HierarchySnapshot::new(&parent(vec![child("child-a", "Done")])); + let repository_id = CanonicalRepositoryId::new("github:repo").expect("repository id"); + snapshot.dispatch_required_merge_commits = vec![RequiredMergeCommit { + repository_id: Some(repository_id.clone()), + commit: "merge-commit".to_owned(), + }]; + let encoded = serde_json::to_value(&snapshot).expect("snapshot should encode"); + let decoded: HierarchySnapshot = + serde_json::from_value(encoded).expect("repository-bound state should decode"); + assert_eq!(decoded, snapshot); + + let mut legacy = serde_json::to_value(&snapshot).expect("snapshot should encode"); + legacy["dispatch_required_merge_commits"] = serde_json::json!(["legacy-commit"]); + let decoded: HierarchySnapshot = + serde_json::from_value(legacy).expect("legacy state should remain recoverable"); + assert_eq!( + decoded.dispatch_required_merge_commits, + vec![RequiredMergeCommit { + repository_id: None, + commit: "legacy-commit".to_owned(), + }] + ); + } + + #[test] + fn frozen_scope_change_keeps_in_flight_dispatch_fence_until_replan() { + let mut snapshot = HierarchySnapshot::new(&parent(vec![child("child-a", "Done")])); + snapshot.freeze().expect("initial scope should freeze"); + snapshot.mark_dispatched(); + + assert_eq!( + snapshot.reconcile(&[child("child-b", "Done")]), + HierarchyReconciliation::BlockedForReplanning { generation: 2 } + ); + assert!(!snapshot.dispatch_claimed()); + assert!(snapshot.has_dispatched_execution_fence()); + assert!(snapshot.clear_in_flight_dispatch()); + assert!(!snapshot.has_dispatched_execution_fence()); + + snapshot.replan(); + assert!(!snapshot.has_dispatched_execution_fence()); + } + + #[test] + fn dispatch_intent_restores_a_recovered_in_flight_fence() { + let mut snapshot = HierarchySnapshot::new(&parent(vec![child("child-a", "Done")])); + snapshot.freeze().expect("initial scope should freeze"); + snapshot.mark_dispatch_intent(); + + assert!(snapshot.restore_in_flight_dispatch()); + assert!(snapshot.has_dispatched_execution_fence()); + assert!(!snapshot.restore_in_flight_dispatch()); + } + + #[test] + fn dispatched_generation_restores_a_recovered_in_flight_fence() { + let mut snapshot = HierarchySnapshot::new(&parent(vec![child("child-a", "Done")])); + snapshot.freeze().expect("initial scope should freeze"); + snapshot.mark_dispatched(); + + assert!(snapshot.restore_recovered_dispatch_fence()); + assert!(snapshot.has_in_flight_dispatch()); + assert!(!snapshot.restore_recovered_dispatch_fence()); + } + + #[test] + fn configured_canceled_children_are_non_required_edges() { + let snapshot = HierarchySnapshot::new_with_canceled_states( + &parent(vec![child("child-a", "Canceled"), child("child-b", "Done")]), + &["Canceled".to_owned()], + ); + + assert_eq!(snapshot.required_child_edges.len(), 2); + assert!(!snapshot.required_child_edges[0].required); + assert!(snapshot.required_child_edges[1].required); + } + + #[test] + fn tracker_canceled_kind_makes_custom_terminal_state_non_required() { + let mut duplicate = child("duplicate", "Duplicate"); + duplicate.state_kind = TrackerIssueStateKind::Canceled; + let snapshot = HierarchySnapshot::new(&parent(vec![duplicate])); + + assert!(!snapshot.required_child_edges[0].required); + } + + #[test] + fn identifier_rename_refreshes_edge_without_advancing_generation() { + let parent = parent(vec![child("child-a", "Done")]); + let mut snapshot = HierarchySnapshot::new(&parent); + let generation = snapshot.generation; + let mut renamed = child("child-a", "Done"); + renamed.identifier = "COE-RENAMED".to_owned(); + + assert_eq!( + snapshot.reconcile(&[renamed]), + HierarchyReconciliation::Unchanged + ); + assert_eq!(snapshot.generation, generation); + assert_eq!( + snapshot.required_child_edges[0].child_identifier, + IssueIdentifier::new("COE-RENAMED").expect("identifier") + ); + } + + #[test] + fn required_leases_are_atomic_and_release_leaf_only_after_acquisition() { + let resource = LeaseResource { + issue_id: IssueId::new("child").expect("id"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState::default(); + state + .acquire_leases(vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&resource.issue_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }]) + .expect("leaf lease should acquire"); + let parent_id = IssueId::new("parent").expect("id"); + state + .acquire_required_and_release_leaf( + vec![LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 2, + expires_at: None, + released_at: None, + }], + std::slice::from_ref(&resource), + 3, + ) + .expect("required lease transaction should succeed"); + assert!(state.active_for(&resource)); + assert!( + state.leases.iter().any(|lease| { + lease.kind == LeaseKind::LeafWorker && lease.released_at == Some(3) + }) + ); + } + + #[test] + fn removed_child_edges_release_obsolete_leaf_leases() { + let resource = LeaseResource { + issue_id: IssueId::new("child").expect("id"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let parent_id = IssueId::new("parent").expect("id"); + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([( + parent_id.clone(), + HierarchySnapshot { + parent_id: parent_id.clone(), + generation: 2, + required_child_edges: Vec::new(), + frozen: false, + blocked_reason: None, + eligibility_blocked_reason: None, + dispatched_generation: None, + dispatch_intent_generation: None, + in_flight_generation: None, + dispatch_required_merge_commits: Vec::new(), + }, + )]), + ..Default::default() + }; + state + .acquire_leases(vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&resource.issue_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }]) + .expect("leaf lease should acquire"); + + state.release_obsolete_leaf_leases(std::slice::from_ref(&resource.issue_id), 2); + assert_eq!(state.leases[0].released_at, Some(2)); + } + + #[test] + fn removed_subtree_preserves_leaf_lease_reparented_to_another_hierarchy() { + let parent_a = IssueId::new("parent-a").expect("parent a"); + let parent_b = IssueId::new("parent-b").expect("parent b"); + let child_id = IssueId::new("child").expect("child"); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([ + ( + parent_a.clone(), + HierarchySnapshot::new(&parent(vec![child("child", "Done")])), + ), + ( + parent_b, + HierarchySnapshot::new(&parent(vec![child("child", "Done")])), + ), + ]), + ..Default::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: LeaseOwner::ancestor(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + ]) + .expect("leaf lease should acquire"); + + assert!(!state.release_removed_subtree_leases( + &parent_a, + std::slice::from_ref(&child_id), + None, + 2, + )); + assert!(state.leases.iter().all(LeaseRecord::active)); + } + + #[test] + fn removed_subtree_preserves_lease_reparented_to_unmaterialized_parent() { + let parent_a = IssueId::new("parent-a").expect("parent a"); + let parent_b = IssueId::new("parent-b").expect("parent b"); + let child_id = IssueId::new("child").expect("child"); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([( + parent_a.clone(), + HierarchySnapshot::new(&parent(vec![child("child", "Done")])), + )]), + ..Default::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: LeaseOwner::ancestor(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + ]) + .expect("leases should acquire"); + let reachable_child_edges = BTreeSet::from([(parent_b, child_id.clone())]); + + assert!(!state.release_removed_subtree_leases( + &parent_a, + std::slice::from_ref(&child_id), + Some(&reachable_child_edges), + 2, + )); + assert!(state.leases.iter().all(LeaseRecord::active)); + } + + #[test] + fn reparented_subtree_preserves_required_descendant_leases() { + let parent_a = IssueId::new("parent-a").expect("parent a"); + let parent_b = IssueId::new("parent-b").expect("parent b"); + let nested_id = IssueId::new("nested").expect("nested"); + let leaf_id = IssueId::new("leaf").expect("leaf"); + let canceled_id = IssueId::new("canceled").expect("canceled"); + let mut nested = parent(vec![child("leaf", "Done"), child("canceled", "Canceled")]); + nested.id = nested_id.to_string(); + let resource = |issue_id: &IssueId| LeaseResource { + issue_id: issue_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }; + for materialized_parent in [false, true] { + for undispatched in [false, true] { + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([ + ( + parent_a.clone(), + HierarchySnapshot::new(&parent(vec![child("nested", "Done")])), + ), + (nested_id.clone(), HierarchySnapshot::new(&nested)), + ]), + ..Default::default() + }; + if materialized_parent { + state.hierarchy.insert( + parent_b.clone(), + HierarchySnapshot::new(&parent(vec![child("nested", "Done")])), + ); + } + for (parent_id, snapshot) in &mut state.hierarchy { + snapshot.parent_id = parent_id.clone(); + } + state.validate().expect("valid durable hierarchy"); + // Include the canceled leaf in the old removed scope, but never + // connect it to the retained nested parent by a required edge. + for issue_id in [&leaf_id, &canceled_id] { + state + .acquire_leases(vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource(issue_id), + owner: LeaseOwner::leaf_worker(issue_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }]) + .expect("leaf lease"); + } + for (kind, owner) in [ + ( + LeaseKind::AncestorIntegration, + LeaseOwner::ancestor(&nested_id), + ), + ( + LeaseKind::Review, + LeaseOwner::review_for_parent(&nested_id, &leaf_id), + ), + ] { + state + .acquire_leases(vec![LeaseRecord { + kind, + owner, + resource: resource(&leaf_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }]) + .expect("descendant evidence"); + } + state + .hierarchy + .get_mut(&parent_a) + .expect("parent a") + .required_child_edges + .push(HierarchyChildEdge::from(&child("canceled", "Done"))); + let edges = BTreeSet::from([ + (parent_b.clone(), nested_id.clone()), + (nested_id.clone(), leaf_id.clone()), + ]); + let reachable = (!materialized_parent).then_some(&edges); + if undispatched { + state.release_subtree_evidence_for_undispatched_parent_with_reachability( + &parent_a, reachable, 2, + ); + } else { + state.release_removed_subtree_leases( + &parent_a, + &[nested_id.clone(), canceled_id.clone()], + reachable, + 2, + ); + } + assert!( + state.active_for(&resource(&leaf_id)), + "required descendant lost its only lease" + ); + assert!( + state + .leases + .iter() + .filter(|lease| lease.resource.issue_id == leaf_id) + .all(LeaseRecord::active), + "retained descendant owner was released" + ); + assert!( + !state.active_for(&resource(&canceled_id)), + "canceled edge retained evidence" + ); + } + } + } + + #[test] + fn removed_nested_subtree_releases_its_ancestor_and_review_owners() { + let parent_id = IssueId::new("parent").expect("parent"); + let nested_id = IssueId::new("nested").expect("nested"); + let leaf_id = IssueId::new("leaf").expect("leaf"); + let mut nested_issue = parent(vec![child("leaf", "Done")]); + nested_issue.id = nested_id.to_string(); + let state_parent = parent(vec![child("nested", "Done")]); + let resource = |issue_id: &IssueId| LeaseResource { + issue_id: issue_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([ + (parent_id.clone(), HierarchySnapshot::new(&state_parent)), + (nested_id.clone(), HierarchySnapshot::new(&nested_issue)), + ]), + ..Default::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource(&leaf_id), + owner: LeaseOwner::leaf_worker(&leaf_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource(&leaf_id), + owner: LeaseOwner::ancestor(&nested_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::Review, + resource: resource(&nested_id), + owner: LeaseOwner::review_for_parent(&parent_id, &nested_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::Review, + resource: resource(&leaf_id), + owner: LeaseOwner::review_for_parent(&nested_id, &leaf_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource(&nested_id), + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + ]) + .expect("nested leases should acquire"); + + assert!(state.release_removed_subtree_leases( + &parent_id, + std::slice::from_ref(&nested_id), + None, + 2, + )); + assert!( + state.leases[..4] + .iter() + .all(|lease| lease.released_at == Some(2)) + ); + assert!(state.leases[4].active()); + } + + #[test] + fn obsolete_run_boundaries_are_pruned_after_hierarchy_and_lease_release() { + let retained = IssueId::new("retained").expect("retained"); + let obsolete = IssueId::new("obsolete").expect("obsolete"); + let mut state = DurableOrchestratorState { + run_hierarchy_generations: BTreeMap::from([(obsolete.clone(), 1)]), + run_started_at_by_issue: BTreeMap::from([ + (retained.clone(), TimestampMs::new(10)), + (obsolete.clone(), TimestampMs::new(20)), + ]), + terminal_orchestrator_issues: BTreeSet::from([retained.clone(), obsolete]), + ..Default::default() + }; + + assert!(state.prune_obsolete_run_boundaries(&BTreeSet::from([retained.clone()]))); + assert_eq!( + state.run_started_at_by_issue, + BTreeMap::from([(retained.clone(), TimestampMs::new(10))]) + ); + assert!(state.run_hierarchy_generations.is_empty()); + assert_eq!( + state.terminal_orchestrator_issues, + BTreeSet::from([retained]) + ); + } + + #[test] + fn parent_release_keeps_higher_ancestor_and_nested_resources() { + let resource = LeaseResource { + issue_id: IssueId::new("leaf").expect("id"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let intermediate = IssueId::new("intermediate").expect("id"); + let higher = IssueId::new("higher").expect("id"); + let intermediate_issue = TrackerIssue { + id: intermediate.to_string(), + identifier: "COE-INTERMEDIATE".to_owned(), + sub_issues: vec![child("leaf", "Done")], + ..parent(Vec::new()) + }; + let mut intermediate_snapshot = HierarchySnapshot::new(&intermediate_issue); + intermediate_snapshot.generation = 2; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([(intermediate.clone(), intermediate_snapshot)]), + ..Default::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&intermediate), + hierarchy_generation: 2, + acquired_at: 2, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::Review, + resource: resource.clone(), + owner: LeaseOwner::review_for_parent(&intermediate, &resource.issue_id), + hierarchy_generation: 2, + acquired_at: 2, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&higher), + hierarchy_generation: 1, + acquired_at: 3, + expires_at: None, + released_at: None, + }, + ]) + .expect("nested leases should acquire"); + + assert_eq!( + state.descendant_resources_for(&intermediate), + vec![resource.clone()] + ); + assert!(state.release_parent_leases(&intermediate, 4)); + assert!( + state.leases.iter().any(|lease| { + lease.owner == LeaseOwner::ancestor(&higher) && lease.active_at(4) + }) + ); + assert!( + state + .leases + .iter() + .filter(|lease| { + lease.owner == LeaseOwner::ancestor(&intermediate) + || lease.owner + == LeaseOwner::review_for_parent(&intermediate, &resource.issue_id) + }) + .all(|lease| lease.released_at == Some(4)) + ); + } + + #[test] + fn descendant_resources_ignore_removed_child_subtrees() { + let parent_id = IssueId::new("parent").expect("parent"); + let current_child_id = IssueId::new("current-child").expect("current child"); + let removed_child_id = IssueId::new("removed-child").expect("removed child"); + let repository_id = CanonicalRepositoryId::new("github:repo").expect("repository"); + let current_resource = LeaseResource { + issue_id: current_child_id, + repository_id: repository_id.clone(), + checkout_generation: "current".to_owned(), + }; + let removed_resource = LeaseResource { + issue_id: removed_child_id, + repository_id, + checkout_generation: "removed".to_owned(), + }; + let snapshot = HierarchySnapshot::new(&parent(vec![child("current-child", "Done")])); + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot)]), + ..Default::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: current_resource.clone(), + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: removed_resource, + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 2, + expires_at: None, + released_at: None, + }, + ]) + .expect("ancestor resources should acquire"); + + assert_eq!( + state.descendant_resources_for(&parent_id), + vec![current_resource] + ); + } + + #[test] + fn ancestor_resources_for_child_ignore_sibling_resources() { + let parent_id = IssueId::new("parent").expect("parent"); + let first_child_id = IssueId::new("first-child").expect("first child"); + let second_child_id = IssueId::new("second-child").expect("second child"); + let repository_id = CanonicalRepositoryId::new("github:repo").expect("repository"); + let first_resource = LeaseResource { + issue_id: first_child_id.clone(), + repository_id: repository_id.clone(), + checkout_generation: "first".to_owned(), + }; + let second_resource = LeaseResource { + issue_id: second_child_id.clone(), + repository_id, + checkout_generation: "second".to_owned(), + }; + let snapshot = HierarchySnapshot::new(&parent(vec![ + child("first-child", "Done"), + child("second-child", "Done"), + ])); + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot)]), + ..Default::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: first_resource.clone(), + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: second_resource, + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 2, + expires_at: None, + released_at: None, + }, + ]) + .expect("ancestor resources should acquire"); + + assert_eq!( + state.ancestor_resources_for_child(&parent_id, &first_child_id), + vec![first_resource] + ); + } + + #[test] + fn tracker_terminal_state_alone_is_not_parent_eligibility() { + let parent = parent(vec![child("child-a", "Done")]); + let snapshot = HierarchySnapshot::new(&parent); + let evidence = ParentEligibilityEvidence::tracker_only(&parent, snapshot.generation); + + assert_eq!( + evidence.eligible_for(&snapshot), + Err(HierarchyBlockedReason::UnresolvedFailure( + "child orchestrator outcome is not terminal".to_owned() + )) + ); + } + + #[test] + fn provider_evidence_requires_merge_commit_and_retained_generation() { + let parent = parent(vec![child("child-a", "Done")]); + let snapshot = HierarchySnapshot::new(&parent); + let child_id = IssueId::new("child-a").expect("id"); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut evidence = ParentEligibilityEvidence { + hierarchy_generation: snapshot.generation, + children: vec![ChildEligibilityEvidence { + child_id, + hierarchy_generation: snapshot.generation, + orchestrator_terminal: true, + provider_merge_confirmed: true, + merge_required: true, + merge_result_commit: Some("abc123".to_owned()), + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: Vec::new(), + merge_repository_id: None, + merge_repository_ids: Vec::new(), + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: Some(resource.clone()), + resources: Vec::new(), + unresolved_failure: None, + }], + }; + assert!(evidence.eligible_for(&snapshot).is_ok()); + evidence.children[0].merge_result_commit = None; + assert_eq!( + evidence.eligible_for(&snapshot), + Err(HierarchyBlockedReason::MissingTargetCommit) + ); + } + + #[test] + fn nested_evidence_accepts_recorded_descendant_commits() { + let parent = parent(vec![child("nested-parent", "Done")]); + let snapshot = HierarchySnapshot::new(&parent); + let resource = LeaseResource { + issue_id: IssueId::new("leaf").expect("id"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut evidence = ParentEligibilityEvidence { + hierarchy_generation: snapshot.generation, + children: vec![ChildEligibilityEvidence { + child_id: IssueId::new("nested-parent").expect("id"), + hierarchy_generation: snapshot.generation, + orchestrator_terminal: true, + provider_merge_confirmed: true, + merge_required: true, + merge_result_commit: None, + merge_result_commits: vec!["descendant-merge".to_owned()], + merge_result_commits_by_repository: Vec::new(), + merge_repository_id: None, + merge_repository_ids: Vec::new(), + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: None, + resources: vec![resource], + unresolved_failure: None, + }], + }; + assert!(evidence.eligible_for(&snapshot).is_ok()); + evidence.children[0].merge_repository_ids = + vec![CanonicalRepositoryId::new("github:other-repo").expect("repository")]; + assert_eq!( + evidence.eligible_for(&snapshot), + Err(HierarchyBlockedReason::MissingCheckoutEvidence) + ); + } + + #[test] + fn canceled_repository_neutral_subtree_is_resolved_without_merge_evidence() { + let parent = parent(vec![child("nested-parent", "Done")]); + let snapshot = HierarchySnapshot::new(&parent); + let evidence = ParentEligibilityEvidence { + hierarchy_generation: snapshot.generation, + children: vec![ChildEligibilityEvidence { + child_id: IssueId::new("nested-parent").expect("id"), + hierarchy_generation: snapshot.generation, + orchestrator_terminal: true, + provider_merge_confirmed: false, + merge_required: false, + merge_result_commit: None, + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: Vec::new(), + merge_repository_id: None, + merge_repository_ids: Vec::new(), + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: None, + resources: Vec::new(), + unresolved_failure: None, + }], + }; + + assert!(evidence.eligible_for(&snapshot).is_ok()); + } + + #[test] + fn nested_ancestor_leases_keep_distinct_owner_edges() { + let resource = LeaseResource { + issue_id: IssueId::new("child").expect("id"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let first_parent = IssueId::new("parent-a").expect("id"); + let second_parent = IssueId::new("parent-b").expect("id"); + let mut state = DurableOrchestratorState::default(); + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&first_parent), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&second_parent), + hierarchy_generation: 2, + acquired_at: 2, + expires_at: None, + released_at: None, + }, + ]) + .expect("nested owners should coexist"); + + state.release_owner(&LeaseOwner::ancestor(&first_parent), 3); + + assert_eq!( + state.leases.iter().filter(|lease| lease.active()).count(), + 1 + ); + assert_eq!(state.leases[1].owner, LeaseOwner::ancestor(&second_parent)); + } + + #[test] + fn stale_binding_resource_release_clears_all_owners() { + let issue_id = IssueId::new("child").expect("id"); + let resource = LeaseResource { + issue_id: issue_id.clone(), + repository_id: CanonicalRepositoryId::new("github:old-repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState::default(); + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&issue_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&IssueId::new("parent").expect("parent")), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + ]) + .expect("leases should acquire"); + + assert!(state.release_resource_leases(&resource, 2)); + assert!(state.leases.iter().all(|lease| !lease.active())); + } + + #[test] + fn dispatched_ancestor_fence_ends_when_its_lease_is_released() { + let parent_id = IssueId::new("parent").expect("parent id"); + let child_id = IssueId::new("child").expect("child id"); + let parent = parent(vec![child("child", "Done")]); + let mut snapshot = HierarchySnapshot::new(&parent); + snapshot.mark_dispatched(); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository id"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot)]), + ..Default::default() + }; + state + .acquire_leases(vec![LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }]) + .expect("ancestor lease should acquire"); + + assert!(state.has_active_dispatched_ancestor(&child_id)); + state.release_owner(&LeaseOwner::ancestor(&parent_id), 2); + assert!(!state.has_active_dispatched_ancestor(&child_id)); + } + + #[test] + fn active_dispatched_ancestor_fence_walks_nested_hierarchy() { + let root_id = IssueId::new("root").expect("root id"); + let nested_id = IssueId::new("nested").expect("nested id"); + let leaf_id = IssueId::new("leaf").expect("leaf id"); + let root = parent(vec![child("nested", "Done")]); + let nested = TrackerIssue { + id: "nested".to_owned(), + identifier: "COE-2".to_owned(), + sub_issues: vec![child("leaf", "Done")], + ..parent(Vec::new()) + }; + let mut root_snapshot = HierarchySnapshot::new(&root); + root_snapshot.mark_dispatched(); + let resource = LeaseResource { + issue_id: leaf_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository id"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([ + (root_id.clone(), root_snapshot), + (nested_id, HierarchySnapshot::new(&nested)), + ]), + ..Default::default() + }; + state + .acquire_leases(vec![LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: LeaseOwner::ancestor(&root_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }]) + .expect("root ancestor lease should acquire"); + + assert!(state.has_active_dispatched_ancestor(&leaf_id)); + } + + #[test] + fn undispatched_root_release_clears_nested_child_evidence() { + let root = parent(vec![child("nested-parent", "Done")]); + let nested = TrackerIssue { + id: "nested-parent".to_owned(), + identifier: "COE-2".to_owned(), + sub_issues: vec![child("leaf", "Done")], + ..parent(Vec::new()) + }; + let root_id = IssueId::new("parent").expect("root"); + let nested_id = IssueId::new("nested-parent").expect("nested"); + let leaf_id = IssueId::new("leaf").expect("leaf"); + let resource = LeaseResource { + issue_id: leaf_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([ + (root_id.clone(), HierarchySnapshot::new(&root)), + (nested_id.clone(), HierarchySnapshot::new(&nested)), + ]), + ..DurableOrchestratorState::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&leaf_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&nested_id), + hierarchy_generation: 1, + acquired_at: 2, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::Review, + resource, + owner: LeaseOwner::review_for_parent(&nested_id, &leaf_id), + hierarchy_generation: 1, + acquired_at: 3, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::Review, + resource: LeaseResource { + issue_id: leaf_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo") + .expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }, + owner: LeaseOwner::review_for_parent(&root_id, &leaf_id), + hierarchy_generation: 1, + acquired_at: 3, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: LeaseResource { + issue_id: leaf_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo") + .expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }, + owner: LeaseOwner::ancestor(&root_id), + hierarchy_generation: 1, + acquired_at: 5, + expires_at: None, + released_at: None, + }, + ]) + .expect("nested evidence leases should acquire"); + + assert!(state.release_subtree_evidence_for_undispatched_parent(&root_id, 4)); + assert!( + state + .leases + .iter() + .all(|lease| lease.released_at == Some(4)) + ); + } + + #[test] + fn undispatched_root_release_preserves_reparented_child_evidence() { + let parent_a = IssueId::new("parent-a").expect("parent a"); + let parent_b = IssueId::new("parent-b").expect("parent b"); + let child_id = IssueId::new("child").expect("child"); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState { + hierarchy: BTreeMap::from([( + parent_a.clone(), + HierarchySnapshot::new(&parent(vec![child("child", "Done")])), + )]), + ..Default::default() + }; + state + .acquire_leases(vec![ + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resource.clone(), + owner: LeaseOwner::ancestor(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: LeaseOwner::ancestor(&parent_a), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }, + ]) + .expect("leases should acquire"); + + let reachable_child_edges = BTreeSet::from([(parent_b, child_id.clone())]); + assert!( + state.release_subtree_evidence_for_undispatched_parent_with_reachability( + &parent_a, + Some(&reachable_child_edges), + 2, + ) + ); + assert!(state.leases.iter().any(|lease| { + lease.active() + && lease.kind == LeaseKind::LeafWorker + && lease.owner == LeaseOwner::leaf_worker(&child_id) + })); + assert!(state.leases.iter().any(|lease| { + lease.active() + && lease.kind == LeaseKind::AncestorIntegration + && lease.owner == LeaseOwner::ancestor(&child_id) + })); + assert!(!state.leases.iter().any(|lease| { + lease.active() + && lease.kind == LeaseKind::AncestorIntegration + && lease.owner == LeaseOwner::ancestor(&parent_a) + })); + } + + #[test] + fn ancestor_leases_rebind_to_an_explicit_replan_generation() { + let parent_id = IssueId::new("parent").expect("parent"); + let resource = LeaseResource { + issue_id: IssueId::new("leaf").expect("leaf"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut state = DurableOrchestratorState::default(); + state + .acquire_leases(vec![LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }]) + .expect("parent lease should acquire"); + + state.rebind_ancestor_leases(&parent_id, 2); + + assert_eq!(state.leases[0].hierarchy_generation, 2); + assert!(state.leases[0].active()); + } + + #[test] + fn durable_state_validation_rejects_unknown_schema_and_invalid_snapshot() { + let state = DurableOrchestratorState { + schema_version: HIERARCHY_STATE_SCHEMA_VERSION + 1, + ..Default::default() + }; + assert!(state.validate().is_err()); + + let parent = parent(vec![child("child-a", "Done")]); + let parent_id = IssueId::new("parent").expect("id"); + let mut state = DurableOrchestratorState::default(); + let mut snapshot = HierarchySnapshot::new(&parent); + snapshot.generation = 0; + state.hierarchy.insert(parent_id, snapshot); + assert!(state.validate().is_err()); + } + + #[test] + fn expired_leases_are_not_active() { + let resource = LeaseResource { + issue_id: IssueId::new("child").expect("id"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }; + let lease = LeaseRecord { + kind: LeaseKind::DiagnosticHold, + resource, + owner: LeaseOwner::diagnostic("operator"), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: Some(10), + released_at: None, + }; + assert!(lease.active_at(9)); + assert!(!lease.active_at(10)); + } + + #[test] + fn inactive_lease_history_is_deduplicated_and_bounded() { + let issue_id = IssueId::new("reused-child").expect("issue id"); + let resource = LeaseResource { + issue_id: issue_id.clone(), + repository_id: CanonicalRepositoryId::new("github:reused-repo").expect("repository id"), + checkout_generation: "checkout-reused".to_owned(), + }; + let mut state = DurableOrchestratorState { + leases: vec![ + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&issue_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: Some(2), + }, + LeaseRecord { + kind: LeaseKind::LeafWorker, + resource, + owner: LeaseOwner::leaf_worker(&issue_id), + hierarchy_generation: 2, + acquired_at: 3, + expires_at: None, + released_at: Some(4), + }, + ], + ..Default::default() + }; + state + .acquire_leases(Vec::new()) + .expect("compaction should succeed"); + assert_eq!(state.leases.len(), 1); + assert_eq!(state.leases[0].hierarchy_generation, 2); + assert_eq!(state.leases[0].released_at, Some(4)); + + for index in 0..(MAX_INACTIVE_LEASE_HISTORY + 32) { + let issue_id = IssueId::new(format!("child-{index}")).expect("issue id"); + let lease = LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: LeaseResource { + issue_id: issue_id.clone(), + repository_id: CanonicalRepositoryId::new(format!("github:repo-{index}")) + .expect("repository id"), + checkout_generation: format!("checkout-{index}"), + }, + owner: LeaseOwner::leaf_worker(&issue_id), + hierarchy_generation: 1, + acquired_at: index as u64, + expires_at: None, + released_at: None, + }; + state + .acquire_leases(vec![lease]) + .expect("lease should acquire"); + state.release_owner(&LeaseOwner::leaf_worker(&issue_id), index as u64 + 1); + } + + assert_eq!(state.leases.len(), MAX_INACTIVE_LEASE_HISTORY); + assert!(state.leases.iter().all(|lease| !lease.active())); + } +} diff --git a/crates/opensymphony-orchestrator/src/lib.rs b/crates/opensymphony-orchestrator/src/lib.rs index 4ed40175c..1bdf18e1f 100644 --- a/crates/opensymphony-orchestrator/src/lib.rs +++ b/crates/opensymphony-orchestrator/src/lib.rs @@ -1,3 +1,4 @@ +mod hierarchy; mod scheduler; mod selection; @@ -14,6 +15,11 @@ pub use crate::opensymphony_domain::{ TrackerIssueSummary, TrackerStateId, TransitionAction, WorkerAttemptSnapshot, WorkerId, WorkerOutcomeKind, WorkerOutcomeRecord, WorkspaceKey, WorkspaceRecord, }; +pub use hierarchy::{ + ChildEligibilityEvidence, DurableOrchestratorState, HierarchyBlockedReason, HierarchyChildEdge, + HierarchyReconciliation, HierarchySnapshot, LeaseError, LeaseKind, LeaseOwner, LeaseRecord, + LeaseResource, ParentEligibilityEvidence, ProviderEvidenceBoundary, RequiredMergeCommit, +}; pub use scheduler::{ HarnessRouteDecision, RecoveredRun, RecoveryRecord, RetryExhaustionRecord, RetryPendingRecord, Scheduler, SchedulerConfig, SchedulerError, TrackerBackend, WorkerAbortReason, WorkerBackend, @@ -62,6 +68,7 @@ mod tests { }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: Vec::new(), project_id: None, diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 31b756f80..02b3cd801 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -24,11 +24,15 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::{ select, - time::{MissedTickBehavior, interval}, + time::{MissedTickBehavior, interval, timeout}, }; use tracing::{debug, warn}; use super::filter_issues_for_dispatch; +use super::{ + DurableOrchestratorState, HierarchyBlockedReason, HierarchySnapshot, LeaseRecord, + LeaseResource, ParentEligibilityEvidence, +}; const DISABLED_STALL_TIMEOUT_MS: u64 = u64::MAX / 4; const ROUTING_TASK_ISSUE_EXECUTION: &str = "issue_execution"; @@ -38,6 +42,20 @@ const TERMINAL_REFRESH_INTERVAL_MS: u64 = 300_000; const FULL_DETAIL_REFRESH_INTERVAL_MS: u64 = 3_600_000; const HUMAN_REVIEW_STATE: &str = "human review"; const MERGING_STATE: &str = "merging"; +const PARENT_ELIGIBILITY_TIMEOUT: Duration = Duration::from_secs(30); +// A repository-neutral child can fan out into several leaf merge-evidence +// requests. The runtime provider evaluates those leaves eight at a time, so +// reserve one base timeout for each provider batch in the descendant subtree. +const PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY: usize = 8; + +fn parent_eligibility_timeout(provider_work_units: usize) -> Duration { + let batches = provider_work_units + .max(1) + .saturating_add(PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY - 1) + / PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY; + let multiplier = u32::try_from(batches).unwrap_or(u32::MAX); + PARENT_ELIGIBILITY_TIMEOUT.saturating_mul(multiplier) +} fn workspace_key_changed_for_issue(execution: &IssueExecution, issue: &NormalizedIssue) -> bool { let Some(workspace) = execution.workspace() else { @@ -244,6 +262,9 @@ impl HarnessRouteDecision { #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerLaunch { pub conversation: ConversationMetadata, + /// Timestamp persisted by the worker path when the run actually began. + /// This is the boundary used to fence provider evidence for this run. + pub started_at: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -333,10 +354,26 @@ pub trait TrackerBackend { .filter(|issue| requested.contains(&issue.identifier.to_ascii_uppercase())) .collect()) } + async fn issue_by_id(&mut self, issue_id: &str) -> Result, Self::Error> { + Ok(self + .issues_by_identifiers(&[issue_id.to_owned()]) + .await? + .into_iter() + .next()) + } async fn issue_states_by_ids( &mut self, issue_ids: &[String], ) -> Result, Self::Error>; + async fn parent_eligibility( + &mut self, + _parent: &TrackerIssue, + hierarchy: &HierarchySnapshot, + ) -> Result { + Ok(ParentEligibilityEvidence::tracker_only_for_snapshot( + hierarchy, + )) + } fn error_category(_error: &Self::Error) -> Option { None } @@ -357,6 +394,38 @@ pub trait WorkspaceBackend { async fn recover_workspaces(&mut self) -> Result, Self::Error>; + async fn recovered_run_started_at( + &mut self, + ) -> Result, Self::Error> { + Ok(BTreeMap::new()) + } + + async fn load_orchestrator_state(&mut self) -> Result, Self::Error> { + Ok(None) + } + + async fn persist_orchestrator_state( + &mut self, + _state: &serde_json::Value, + ) -> Result<(), Self::Error> { + Ok(()) + } + + async fn workspace_lease_resource( + &mut self, + _issue: &NormalizedIssue, + _workspace: &WorkspaceRecord, + ) -> Result, Self::Error> { + Ok(None) + } + + async fn workspace_has_active_lease( + &mut self, + _workspace: &WorkspaceRecord, + ) -> Result { + Ok(false) + } + async fn recover_retry_exhaustion( &mut self, ) -> Result, Self::Error> { @@ -516,8 +585,15 @@ pub struct Scheduler { running_counts_by_state: HashMap, worker_metadata: HashMap, parent_issue_ids: HashSet, + terminal_undispatched_parent_ids: HashSet, + terminal_child_failure_ids: HashSet, + parent_eligibility_checked_at: BTreeMap, + hierarchy_state: DurableOrchestratorState, + hierarchy_state_dirty: bool, + durable_state_loaded: bool, pending_retry_persistence: BTreeMap, pending_retry_exhaustion_persistence: BTreeMap, + pending_finished_updates: BTreeMap, pending_recovery: Option>, pending_retry_exhaustion: Option>, pending_retry_recovery: Option>, @@ -534,7 +610,10 @@ pub struct Scheduler { } enum DispatchCandidates { - Full(Vec), + Full { + issues: Vec, + reachable_child_edges: BTreeSet<(IssueId, IssueId)>, + }, Summary(Vec), } @@ -554,8 +633,15 @@ where running_counts_by_state: HashMap::new(), worker_metadata: HashMap::new(), parent_issue_ids: HashSet::new(), + terminal_undispatched_parent_ids: HashSet::new(), + terminal_child_failure_ids: HashSet::new(), + parent_eligibility_checked_at: BTreeMap::new(), + hierarchy_state: DurableOrchestratorState::default(), + hierarchy_state_dirty: false, + durable_state_loaded: false, pending_retry_persistence: BTreeMap::new(), pending_retry_exhaustion_persistence: BTreeMap::new(), + pending_finished_updates: BTreeMap::new(), pending_recovery: None, pending_retry_exhaustion: None, pending_retry_recovery: None, @@ -654,7 +740,26 @@ where }, ); - OrchestratorSnapshot::new(generated_at, daemon, issues) + let mut snapshot = OrchestratorSnapshot::new(generated_at, daemon, issues); + snapshot.hierarchy = self + .hierarchy_state + .hierarchy + .iter() + .map(|(issue_id, hierarchy)| { + ( + issue_id.as_str().to_owned(), + crate::opensymphony_domain::HierarchyStateSnapshot { + generation: hierarchy.generation, + blocked_reason: hierarchy + .blocked_reason + .as_ref() + .or(hierarchy.eligibility_blocked_reason.as_ref()) + .map(|reason| format!("{reason:?}")), + }, + ) + }) + .collect(); + snapshot } pub async fn bootstrap( @@ -684,6 +789,62 @@ where self.flush_pending_retry_persistence().await?; self.flush_pending_retry_exhaustion_persistence().await?; + self.expire_linear_cooldown(observed_at); + let mut pre_update_full_snapshot = if !self.linear_cooldown_active(observed_at) + && due( + self.last_full_detail_refresh_at, + FULL_DETAIL_REFRESH_INTERVAL_MS, + observed_at, + ) { + if let Some(tracker_snapshot) = self.load_tracker_snapshot(observed_at).await? { + self.record_full_detail_refresh(observed_at); + let hierarchy_changed = self.reconcile_hierarchy_snapshots(&tracker_snapshot)?; + let terminal_hierarchy_changed = + self.reconcile_terminal_hierarchy_snapshots(&tracker_snapshot)?; + if hierarchy_changed || terminal_hierarchy_changed || self.hierarchy_state_dirty { + self.persist_orchestrator_state().await?; + } + self.fence_hierarchy_changed_runs(observed_at).await?; + Some(tracker_snapshot) + } else { + None + } + } else { + None + }; + + if !self.linear_cooldown_active(observed_at) + && pre_update_full_snapshot.is_none() + && !self.pending_finished_updates.is_empty() + && let Some(tracker_snapshot) = self.load_tracker_snapshot(observed_at).await? + { + self.record_full_detail_refresh(observed_at); + let hierarchy_changed = self.reconcile_hierarchy_snapshots(&tracker_snapshot)?; + let terminal_hierarchy_changed = + self.reconcile_terminal_hierarchy_snapshots(&tracker_snapshot)?; + if hierarchy_changed || terminal_hierarchy_changed || self.hierarchy_state_dirty { + self.persist_orchestrator_state().await?; + } + self.fence_hierarchy_changed_runs(observed_at).await?; + pre_update_full_snapshot = Some(tracker_snapshot); + } + + if !self.linear_cooldown_active(observed_at) + && pre_update_full_snapshot.is_none() + && due( + self.last_running_state_refresh_at, + RUNNING_STATE_REFRESH_INTERVAL_MS, + observed_at, + ) + && self.has_reconcilable_executions() + { + self.refresh_running_issue_states(observed_at).await?; + } + + if pre_update_full_snapshot.is_some() { + self.flush_pending_finished_updates().await?; + } + let updates = self .worker .poll_updates() @@ -693,21 +854,37 @@ where })?; self.apply_worker_updates(updates).await?; - self.expire_linear_cooldown(observed_at); let mut dispatch_candidates = None; + if let Some(tracker_snapshot) = pre_update_full_snapshot.as_ref() { + self.bootstrap_recovery(tracker_snapshot, observed_at) + .await?; + self.reconcile_tracker_state(tracker_snapshot, observed_at) + .await?; + dispatch_candidates = Some(DispatchCandidates::Full { + issues: tracker_snapshot.active.clone(), + reachable_child_edges: self + .required_child_edges_in_tracker_snapshot(tracker_snapshot), + }); + } if !self.linear_cooldown_active(observed_at) { - if due( - self.last_full_detail_refresh_at, - FULL_DETAIL_REFRESH_INTERVAL_MS, - observed_at, - ) { + if pre_update_full_snapshot.is_none() + && due( + self.last_full_detail_refresh_at, + FULL_DETAIL_REFRESH_INTERVAL_MS, + observed_at, + ) + { if let Some(tracker_snapshot) = self.load_tracker_snapshot(observed_at).await? { self.record_full_detail_refresh(observed_at); self.bootstrap_recovery(&tracker_snapshot, observed_at) .await?; self.reconcile_tracker_state(&tracker_snapshot, observed_at) .await?; - dispatch_candidates = Some(DispatchCandidates::Full(tracker_snapshot.active)); + dispatch_candidates = Some(DispatchCandidates::Full { + reachable_child_edges: self + .required_child_edges_in_tracker_snapshot(&tracker_snapshot), + issues: tracker_snapshot.active, + }); } } else if due( self.last_terminal_refresh_at, @@ -716,14 +893,6 @@ where ) { self.refresh_terminal_issues(observed_at).await?; } - if due( - self.last_running_state_refresh_at, - RUNNING_STATE_REFRESH_INTERVAL_MS, - observed_at, - ) && self.has_reconcilable_executions() - { - self.refresh_running_issue_states(observed_at).await?; - } if due( self.last_dispatch_discovery_at, DISPATCH_DISCOVERY_INTERVAL_MS, @@ -738,8 +907,12 @@ where if let Some(candidates) = dispatch_candidates { match candidates { - DispatchCandidates::Full(candidates) => { - self.dispatch_ready_issues(&candidates, observed_at).await?; + DispatchCandidates::Full { + issues, + reachable_child_edges, + } => { + self.dispatch_ready_issues(&issues, observed_at, Some(&reachable_child_edges)) + .await?; } DispatchCandidates::Summary(candidates) => { self.dispatch_summary_candidates(&candidates, observed_at) @@ -752,7 +925,7 @@ where let known_candidates = self.known_dispatch_candidates(observed_at); if !known_candidates.is_empty() { - self.dispatch_ready_issues(&known_candidates, observed_at) + self.dispatch_ready_issues(&known_candidates, observed_at, None) .await?; } @@ -810,6 +983,156 @@ where Ok(true) } + /// Clear a durable `HierarchyChanged` block after an operator or parent + /// controller has deliberately accepted the current child-edge snapshot. + /// The transition keeps retained evidence and leases intact; the next + /// eligibility pass must reacquire the current generation's leases. + pub async fn replan_parent( + &mut self, + parent_id: &IssueId, + _observed_at: TimestampMs, + ) -> Result { + self.load_recovery_state().await?; + let Some(snapshot) = self.hierarchy_state.hierarchy.get(parent_id) else { + return Ok(false); + }; + if snapshot.blocked_reason != Some(HierarchyBlockedReason::HierarchyChanged) { + return Ok(false); + } + if self.executions.get(parent_id).is_some_and(|execution| { + matches!( + execution.status(), + SchedulerStatus::Claimed | SchedulerStatus::Running + ) + }) { + return Err(SchedulerError::Workspace { + detail: format!( + "cannot replan hierarchy parent {parent_id} while its fenced execution is still running" + ), + }); + } + + let previous_state = self.hierarchy_state.clone(); + let generation = if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(parent_id) { + snapshot.replan(); + Some(snapshot.generation) + } else { + None + }; + if let Some(generation) = generation { + self.hierarchy_state + .rebind_ancestor_leases(parent_id, generation); + } + self.parent_eligibility_checked_at.remove(parent_id); + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + Ok(true) + } + + /// Replan a hierarchy parent addressed by the tracker identifier exposed + /// by the gateway action envelope. The scheduler resolves the identifier + /// through the tracker before mutating its durable hierarchy state. + pub async fn replan_parent_target( + &mut self, + target: &str, + accepted_generation: u64, + observed_at: TimestampMs, + ) -> Result { + self.load_recovery_state().await?; + let lookup_target = self + .executions + .values() + .find(|execution| { + execution.conversation().is_some_and(|conversation| { + let conversation_id = conversation.conversation_id.as_str(); + conversation_id.eq_ignore_ascii_case(target) + || conversation_id_suffix(conversation_id).eq_ignore_ascii_case(target) + }) + }) + .map(|execution| execution.issue().identifier.as_str().to_owned()) + .unwrap_or_else(|| target.to_owned()); + let issues = match self + .tracker + .issues_by_identifiers(std::slice::from_ref(&lookup_target)) + .await + { + Ok(issues) => issues, + Err(error) => { + if T::error_category(&error) == Some(TrackerErrorCategory::NotFound) { + warn!(target = %lookup_target, "replan target disappeared before execution"); + return Ok(false); + } + return Err(SchedulerError::Tracker { + detail: error.to_string(), + }); + } + }; + let Some(issue) = issues.into_iter().find(|issue| { + issue.identifier.eq_ignore_ascii_case(&lookup_target) || issue.id == lookup_target + }) else { + return Ok(false); + }; + let issue_id = IssueId::new(issue.id.clone())?; + let Some(expected_generation) = self + .hierarchy_state + .hierarchy + .get(&issue_id) + .filter(|snapshot| { + snapshot.blocked_reason == Some(HierarchyBlockedReason::HierarchyChanged) + }) + .map(|snapshot| snapshot.generation) + else { + return Ok(false); + }; + if expected_generation != accepted_generation { + warn!( + parent = %lookup_target, + accepted_generation, + current_generation = expected_generation, + "rejecting replan for a stale accepted hierarchy generation" + ); + return Ok(false); + } + + // Refresh the child edges before clearing the durable block. If the + // accepted snapshot changed while the action was in flight, preserve + // the new HierarchyChanged fence instead of silently accepting scope + // the operator did not explicitly replan. + let tracker_snapshot = self + .load_tracker_snapshot(observed_at) + .await? + .ok_or_else(|| SchedulerError::Tracker { + detail: "replan reachability refresh deferred by tracker rate limit".to_owned(), + })?; + let reachable_child_edges = + self.required_child_edges_in_tracker_snapshot(&tracker_snapshot); + let previous_state = self.hierarchy_state.clone(); + self.reconcile_hierarchy_issue(&issue, Some(&reachable_child_edges))?; + let current_generation = self + .hierarchy_state + .hierarchy + .get(&issue_id) + .map(|snapshot| snapshot.generation); + if self.hierarchy_state != previous_state + && let Err(error) = self.persist_orchestrator_state().await + { + self.hierarchy_state = previous_state; + return Err(error); + } + if current_generation != Some(expected_generation) { + warn!( + parent = %lookup_target, + expected_generation, + current_generation = ?current_generation, + "rejecting replan for a hierarchy generation that changed before execution" + ); + return Ok(false); + } + self.replan_parent(&issue_id, observed_at).await + } + pub async fn run_until_shutdown(&mut self, shutdown: F) -> Result<(), SchedulerError> where F: Future, @@ -898,8 +1221,8 @@ where .map(|(index, issue)| (issue.id.clone(), index)) .collect(); let terminal_state_by_id = terminal - .into_iter() - .map(|issue| (issue.id, issue.state)) + .iter() + .map(|issue| (issue.id.clone(), issue.state.clone())) .collect(); let state_by_id = if lookup_ids.is_empty() { @@ -930,6 +1253,7 @@ where terminal_state_by_id, state_by_id, active, + terminal, })) } @@ -955,26 +1279,63 @@ where return Ok(()); } - let snapshots = match self.tracker.issue_states_by_ids(&issue_ids).await { - Ok(snapshots) => snapshots, - Err(error) => { - if self.set_linear_cooldown_from_tracker_error(&error, observed_at) { - return Ok(()); + let has_running_parent = issue_ids.iter().any(|issue_id| { + IssueId::new(issue_id.clone()) + .ok() + .is_some_and(|issue_id| self.parent_issue_ids.contains(&issue_id)) + }); + let active = if has_running_parent { + match self.tracker.candidate_issues().await { + Ok(active) => active, + Err(error) => { + if self.set_linear_cooldown_from_tracker_error(&error, observed_at) { + return Ok(()); + } + return Err(SchedulerError::Tracker { + detail: error.to_string(), + }); + } + } + } else { + Vec::new() + }; + let active_ids = active + .iter() + .map(|issue| issue.id.as_str()) + .collect::>(); + let state_issue_ids = issue_ids + .into_iter() + .filter(|issue_id| !active_ids.contains(issue_id.as_str())) + .collect::>(); + let snapshots = if state_issue_ids.is_empty() { + Vec::new() + } else { + match self.tracker.issue_states_by_ids(&state_issue_ids).await { + Ok(snapshots) => snapshots, + Err(error) => { + if self.set_linear_cooldown_from_tracker_error(&error, observed_at) { + return Ok(()); + } + return Err(SchedulerError::Tracker { + detail: error.to_string(), + }); } - return Err(SchedulerError::Tracker { - detail: error.to_string(), - }); } }; self.last_running_state_refresh_at = Some(observed_at); let tracker_snapshot = TrackerSnapshot { - active: Vec::new(), - active_index: HashMap::new(), + active_index: active + .iter() + .enumerate() + .map(|(index, issue)| (issue.id.clone(), index)) + .collect(), terminal_state_by_id: HashMap::new(), state_by_id: snapshots .into_iter() .map(|snapshot| (snapshot.id.clone(), snapshot)) .collect(), + active, + terminal: Vec::new(), }; self.reconcile_tracker_state(&tracker_snapshot, observed_at) .await @@ -1000,10 +1361,11 @@ where active: Vec::new(), active_index: HashMap::new(), terminal_state_by_id: terminal - .into_iter() - .map(|issue| (issue.id, issue.state)) + .iter() + .map(|issue| (issue.id.clone(), issue.state.clone())) .collect(), state_by_id: HashMap::new(), + terminal, }; self.reconcile_tracker_state(&tracker_snapshot, observed_at) .await @@ -1061,55 +1423,508 @@ where }; } - fn set_linear_cooldown_from_tracker_error( + fn reconcile_hierarchy_snapshots( &mut self, - error: &T::Error, - observed_at: TimestampMs, - ) -> bool { - if T::error_category(error) != Some(TrackerErrorCategory::RateLimited) { - return false; + tracker_snapshot: &TrackerSnapshot, + ) -> Result { + let reachable_child_edges = self.required_child_edges_in_tracker_snapshot(tracker_snapshot); + let mut durable_state_changed = false; + for tracker_issue in &tracker_snapshot.active { + durable_state_changed |= + self.reconcile_hierarchy_issue(tracker_issue, Some(&reachable_child_edges))?; } - - let delay_ms = T::retry_after(error) - .map(duration_millis_saturating) - .unwrap_or(self.config.poll_interval_ms) - .max(self.config.poll_interval_ms); - let blocked_until = observed_at.saturating_add(DurationMs::new(delay_ms)); - self.linear_blocked_until = Some( - self.linear_blocked_until - .map_or(blocked_until, |existing| existing.max(blocked_until)), - ); - self.health = HealthStatus::Degraded; - warn!( - delay_ms, - blocked_until_ms = blocked_until.as_u64(), - "Linear tracker is rate limited; deferring Linear reads" - ); - true + self.hierarchy_state_dirty |= durable_state_changed; + Ok(durable_state_changed) } - async fn bootstrap_recovery( + fn reconcile_terminal_hierarchy_snapshots( &mut self, tracker_snapshot: &TrackerSnapshot, - observed_at: TimestampMs, - ) -> Result<(), SchedulerError> { - if self.recovered { - return Ok(()); + ) -> Result { + for issue in &tracker_snapshot.terminal { + let Ok(issue_id) = IssueId::new(issue.id.clone()) else { + continue; + }; + if self + .hierarchy_state + .hierarchy + .get(&issue_id) + .is_none_or(|snapshot| !snapshot.has_dispatched_execution_fence()) + { + self.terminal_undispatched_parent_ids.insert(issue_id); + } else { + self.terminal_undispatched_parent_ids.remove(&issue_id); + } + } + let issues = tracker_snapshot + .terminal + .iter() + .filter(|issue| { + IssueId::new(issue.id.clone()) + .is_ok_and(|issue_id| self.hierarchy_state.hierarchy.contains_key(&issue_id)) + }) + .cloned() + .collect::>(); + if issues.is_empty() { + return Ok(false); + } + let reachable_child_edges = self.required_child_edges_in_tracker_snapshot(tracker_snapshot); + let mut changed = false; + for issue in issues { + changed |= self.reconcile_hierarchy_issue(&issue, Some(&reachable_child_edges))?; } + Ok(changed) + } - let Some(records) = self.pending_recovery.take() else { - self.recovered = true; - return Ok(()); - }; + fn required_child_edges_in_tracker_snapshot( + &self, + tracker_snapshot: &TrackerSnapshot, + ) -> BTreeSet<(IssueId, IssueId)> { + let mut edges = BTreeSet::new(); + for issue in tracker_snapshot + .active + .iter() + .chain(tracker_snapshot.terminal.iter()) + { + let Ok(issue_id) = IssueId::new(issue.id.clone()) else { + continue; + }; + let snapshot = + HierarchySnapshot::new_with_canceled_states(issue, &self.config.terminal_states); + edges.extend( + snapshot + .required_child_edges + .into_iter() + .filter(|edge| edge.required) + .map(|edge| (issue_id.clone(), edge.child_id)), + ); + if !is_canceled_tracker_issue(issue, &self.config.terminal_states) + && let Some(parent_id) = issue + .parent_id + .as_deref() + .and_then(|parent_id| IssueId::new(parent_id.to_owned()).ok()) + { + edges.insert((parent_id, issue_id)); + } + } + edges + } - let mut pending_retry_by_issue = self - .pending_retry_recovery - .take() + async fn fence_hierarchy_changed_runs( + &mut self, + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let mut cleared_in_flight_fence = false; + let issue_ids = self + .hierarchy_state + .hierarchy + .iter() + .filter(|(_, snapshot)| { + snapshot.blocked_reason == Some(HierarchyBlockedReason::HierarchyChanged) + }) + .filter_map(|(issue_id, _)| { + self.executions.get(issue_id).and_then(|execution| { + matches!( + execution.status(), + SchedulerStatus::Claimed | SchedulerStatus::Running + ) + .then(|| issue_id.clone()) + }) + }) + .collect::>(); + for issue_id in issue_ids { + let Some(issue) = self + .executions + .get(&issue_id) + .map(|execution| execution.issue().clone()) + else { + continue; + }; + self.release_issue( + issue_id.clone(), + issue, + observed_at, + ReleaseReason::TrackerInactive, + false, + Some(WorkerAbortReason::TrackerInactive), + ) + .await?; + if self + .executions + .get(&issue_id) + .is_some_and(|execution| execution.status() == SchedulerStatus::Released) + && let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(&issue_id) + { + cleared_in_flight_fence |= snapshot.clear_in_flight_dispatch(); + } + } + if cleared_in_flight_fence { + self.hierarchy_state_dirty = true; + self.persist_orchestrator_state().await?; + } + Ok(()) + } + + fn reconcile_hierarchy_issue( + &mut self, + tracker_issue: &TrackerIssue, + reachable_child_edges: Option<&BTreeSet<(IssueId, IssueId)>>, + ) -> Result { + let normalized = normalize_tracker_issue(tracker_issue, &self.config)?; + let terminal_failure_resolved = + self.executions + .get(&normalized.id) + .is_some_and(|execution| { + execution.retry().is_none() + && execution.last_worker_outcome().is_some_and(|outcome| { + matches!(outcome.outcome, WorkerOutcomeKind::Succeeded) + }) + }); + if self.terminal_child_failure_ids.contains(&normalized.id) && terminal_failure_resolved { + self.terminal_child_failure_ids.remove(&normalized.id); + } + let existing_snapshot = self.hierarchy_state.hierarchy.get(&normalized.id).cloned(); + if reachable_child_edges.is_none() + && existing_snapshot.as_ref().is_some_and(|snapshot| { + snapshot.required_child_edges + != HierarchySnapshot::new_with_canceled_states( + tracker_issue, + &self.config.terminal_states, + ) + .required_child_edges + }) + { + // Keep the old scope and its leases until the full observation can + // distinguish removed children from children moved to another parent. + self.last_full_detail_refresh_at = None; + return Ok(false); + } + let should_retain_parent_identity = !tracker_issue.sub_issues.is_empty() + || existing_snapshot.as_ref().is_some_and(|snapshot| { + !snapshot.required_child_edges.is_empty() + || snapshot.frozen + || snapshot.blocked_reason.is_some() + }); + if !should_retain_parent_identity { + self.parent_issue_ids.remove(&normalized.id); + self.terminal_undispatched_parent_ids.remove(&normalized.id); + self.terminal_child_failure_ids.remove(&normalized.id); + self.hierarchy_state + .run_hierarchy_generations + .remove(&normalized.id); + if let Some(snapshot) = self.hierarchy_state.hierarchy.remove(&normalized.id) { + let removed_child_ids = snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + .collect::>(); + self.hierarchy_state.release_removed_subtree_leases( + &normalized.id, + &removed_child_ids, + reachable_child_edges, + current_epoch_millis(), + ); + return Ok(true); + } + return Ok(false); + } + self.parent_issue_ids.insert(normalized.id.clone()); + if !self.hierarchy_state.hierarchy.contains_key(&normalized.id) { + let snapshot = HierarchySnapshot::new_with_canceled_states( + tracker_issue, + &self.config.terminal_states, + ); + let terminal_undispatched = normalized.state.category == IssueStateCategory::Terminal + && !snapshot.has_dispatched_execution_fence(); + if terminal_undispatched { + self.terminal_undispatched_parent_ids + .insert(normalized.id.clone()); + } + self.hierarchy_state + .hierarchy + .insert(normalized.id.clone(), snapshot); + if terminal_undispatched { + self.hierarchy_state + .release_subtree_evidence_for_undispatched_parent_with_reachability( + &normalized.id, + reachable_child_edges, + current_epoch_millis(), + ); + } + return Ok(true); + } + let terminal_undispatched = normalized.state.category == IssueStateCategory::Terminal + && self + .hierarchy_state + .hierarchy + .get(&normalized.id) + .is_some_and(|snapshot| !snapshot.has_dispatched_execution_fence()); + if terminal_undispatched { + self.terminal_undispatched_parent_ids + .insert(normalized.id.clone()); + } else { + self.terminal_undispatched_parent_ids.remove(&normalized.id); + } + let released_terminal_parent_evidence = terminal_undispatched + && self + .hierarchy_state + .release_subtree_evidence_for_undispatched_parent_with_reachability( + &normalized.id, + reachable_child_edges, + current_epoch_millis(), + ); + let reactivated_parent = self + .hierarchy_state + .hierarchy + .get(&normalized.id) + .is_some_and(|snapshot| { + snapshot.dispatch_claimed() + && !snapshot.has_in_flight_dispatch() + && normalized.state.category == IssueStateCategory::Active + && !self + .executions + .get(&normalized.id) + .is_some_and(|execution| { + matches!( + execution.status(), + SchedulerStatus::Claimed | SchedulerStatus::Running + ) + }) + && !self.hierarchy_state.leases.iter().any(|lease| { + lease.active() + && normalized.parent_id.is_none() + && (lease.owner == super::LeaseOwner::ancestor(&normalized.id) + || (lease.kind == super::LeaseKind::Review + && lease.owner.id.starts_with(&format!( + "review:{normalized_id}:", + normalized_id = normalized.id + )))) + }) + }); + let reconciliation = + if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(&normalized.id) { + if reactivated_parent { + // Reconcile the detailed child edges before making a + // previously terminal parent dispatchable again. A + // changed scope must advance the generation that the new + // run will use. + snapshot.replan(); + } + let previous_child_ids = snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + .collect::>(); + let reconciliation = snapshot.reconcile_with_canceled_states( + &tracker_issue.sub_issues, + &self.config.terminal_states, + ); + if !matches!(&reconciliation, super::HierarchyReconciliation::Unchanged) { + let current_child_ids = snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + .collect::>(); + let removed_child_ids = previous_child_ids + .difference(¤t_child_ids) + .cloned() + .collect::>(); + Some((snapshot.generation, current_child_ids, removed_child_ids)) + } else { + None + } + } else { + None + }; + if let Some((generation, current_child_ids, removed_child_ids)) = reconciliation { + self.parent_eligibility_checked_at.remove(&normalized.id); + let retained_child_ids = current_child_ids + .iter() + .filter(|child_id| { + !self.executions.get(*child_id).is_some_and(|execution| { + matches!( + execution.status(), + SchedulerStatus::Claimed | SchedulerStatus::Running + ) + }) + }) + .cloned() + .collect::>(); + self.hierarchy_state + .rebind_leaf_leases(&retained_child_ids, generation); + self.hierarchy_state.release_removed_subtree_leases( + &normalized.id, + &removed_child_ids, + reachable_child_edges, + current_epoch_millis(), + ); + for child_id in &retained_child_ids { + self.hierarchy_state + .run_hierarchy_generations + .insert(child_id.clone(), generation); + } + return Ok(true); + } + Ok(released_terminal_parent_evidence || reactivated_parent) + } + + fn set_linear_cooldown_from_tracker_error( + &mut self, + error: &T::Error, + observed_at: TimestampMs, + ) -> bool { + if T::error_category(error) != Some(TrackerErrorCategory::RateLimited) { + return false; + } + + let delay_ms = T::retry_after(error) + .map(duration_millis_saturating) + .unwrap_or(self.config.poll_interval_ms) + .max(self.config.poll_interval_ms); + let blocked_until = observed_at.saturating_add(DurationMs::new(delay_ms)); + self.linear_blocked_until = Some( + self.linear_blocked_until + .map_or(blocked_until, |existing| existing.max(blocked_until)), + ); + self.health = HealthStatus::Degraded; + warn!( + delay_ms, + blocked_until_ms = blocked_until.as_u64(), + "Linear tracker is rate limited; deferring Linear reads" + ); + true + } + + async fn bootstrap_recovery( + &mut self, + tracker_snapshot: &TrackerSnapshot, + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let recovered_in_flight_parent_ids = self + .pending_recovery + .as_ref() + .into_iter() + .flatten() + .filter(|record| record.had_in_flight_run && record.recovered_run.is_some()) + .map(|record| record.issue.id.clone()) + .collect::>(); + for parent_id in &recovered_in_flight_parent_ids { + if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(parent_id) { + self.hierarchy_state_dirty |= snapshot.restore_recovered_dispatch_fence(); + } + } + let hierarchy_changed = self.reconcile_hierarchy_snapshots(tracker_snapshot)?; + let terminal_hierarchy_changed = + self.reconcile_terminal_hierarchy_snapshots(tracker_snapshot)?; + if hierarchy_changed || terminal_hierarchy_changed || self.hierarchy_state_dirty { + self.persist_orchestrator_state().await?; + } + if self.recovered { + return Ok(()); + } + + let Some(records) = self.pending_recovery.take() else { + self.recovered = true; + return Ok(()); + }; + + let mut pending_retry_by_issue = self + .pending_retry_recovery + .take() .unwrap_or_default() .into_iter() .map(|pending| (pending.issue.id.clone(), pending)) .collect::>(); let mut records = records; + // Recovery manifests intentionally do not persist a second hierarchy + // identity. Hydrate the parent edge from the provider's full issue + // detail before terminal cleanup can release an intermediate parent's + // ancestor leases; the higher ancestor may be outside the active + // project scan while the intermediate issue is terminal. + let recovered_parent_ids = tracker_snapshot + .active + .iter() + .chain(tracker_snapshot.terminal.iter()) + .filter_map(|issue| { + let issue_id = IssueId::new(issue.id.clone()).ok()?; + let parent_id = issue + .parent_id + .as_deref() + .and_then(|parent_id| IssueId::new(parent_id.to_owned()).ok())?; + Some((issue_id, parent_id)) + }) + .collect::>(); + for record in &mut records { + if record.issue.parent_id.is_none() + && let Some(parent_id) = recovered_parent_ids.get(&record.issue.id) + { + record.issue.parent_id = Some(parent_id.clone()); + } + } + let mut dispatch_transition_changed = false; + let in_flight_issue_ids = records + .iter() + .filter(|record| record.had_in_flight_run) + .map(|record| record.issue.id.clone()) + .collect::>(); + let terminal_recovered_in_flight_parent_ids = records + .iter() + .filter(|record| { + record.had_in_flight_run + && record.recovered_run.is_some() + && tracker_snapshot.contains_terminal(record.issue.id.as_str()) + }) + .map(|record| record.issue.id.clone()) + .collect::>(); + let intended_parent_ids = self + .hierarchy_state + .hierarchy + .iter() + .filter(|(_, snapshot)| snapshot.dispatch_intended()) + .map(|(parent_id, _)| parent_id.clone()) + .collect::>(); + for parent_id in intended_parent_ids { + if in_flight_issue_ids.contains(&parent_id) { + let nested_child_ids = self + .hierarchy_state + .hierarchy + .get(&parent_id) + .map(|snapshot| { + snapshot + .required_child_edges + .iter() + .filter(|edge| { + edge.required + && self.hierarchy_state.hierarchy.contains_key(&edge.child_id) + }) + .map(|edge| edge.child_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(&parent_id) { + snapshot.mark_dispatched(); + } + if !terminal_recovered_in_flight_parent_ids.contains(&parent_id) { + self.hierarchy_state + .release_leaf_leases_for_parent(&parent_id, observed_at.as_u64()); + self.hierarchy_state.release_ancestor_leases_for_children( + &nested_child_ids, + observed_at.as_u64(), + ); + } + } else { + if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(&parent_id) { + snapshot.clear_dispatch_intent(); + } + } + dispatch_transition_changed = true; + } + if dispatch_transition_changed { + self.persist_orchestrator_state().await?; + } self.recovered_memory_issue_ids .extend(records.iter().map(|record| record.issue.id.clone())); for record in &mut records { @@ -1233,7 +2048,7 @@ where } let mut retry_records = Vec::new(); - for record in records { + for (record_index, record) in records.iter().cloned().enumerate() { let mut recovered_run = record.recovered_run.clone(); let mut recovered_workspace = Some(record.workspace.clone()); if let Some(recovered_run) = recovered_run.as_ref() { @@ -1301,6 +2116,14 @@ where // restoring the retry so the replacement is materialized by // the normal dispatch path. if recovered_run.is_none() && recovered_workspace.is_some() && binding_changed { + self.release_stale_binding_leases( + &record.issue, + recovered_workspace + .as_ref() + .expect("metadata-only recovery workspace should be present"), + observed_at, + ) + .await?; self.workspace .remove_workspace(&record.workspace) .await @@ -1535,6 +2358,104 @@ where } if tracker_snapshot.contains_terminal(issue_id.as_str()) { + if record.successful_run && !record.had_in_flight_run { + self.record_terminal_orchestrator_success(&issue_id).await?; + } + if let Some(recovered_run) = record.recovered_run.as_ref() { + // A tracker terminal state does not prove that a run which + // was in flight at restart has stopped. Fence and stop the + // recovered worker before exposing a terminal outcome to + // parent eligibility. + if !self + .stop_recovered_run_before_workspace_removal( + &record, + recovered_run, + record.harness_kind.as_deref(), + observed_at, + ) + .await? + { + retry_records.push(record); + continue; + } + if record.had_in_flight_run + && self.hierarchy_state.hierarchy.contains_key(&issue_id) + { + let nested_child_ids = self + .hierarchy_state + .hierarchy + .get(&issue_id) + .map(|snapshot| { + snapshot + .required_child_edges + .iter() + .filter(|edge| { + edge.required + && self + .hierarchy_state + .hierarchy + .contains_key(&edge.child_id) + }) + .map(|edge| edge.child_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + self.hierarchy_state + .release_leaf_leases_for_parent(&issue_id, observed_at.as_u64()); + self.hierarchy_state.release_ancestor_leases_for_children( + &nested_child_ids, + observed_at.as_u64(), + ); + self.persist_orchestrator_state().await?; + } + } + if let Err(error) = self + .release_parent_leases_after_finalization( + &issue_id, + record.issue.parent_id.as_ref(), + observed_at, + ) + .await + { + retry_records.push(record); + retry_records.extend(records.iter().skip(record_index + 1).cloned()); + self.pending_recovery = Some(retry_records); + return Err(error); + } + if let Err(error) = self + .retain_terminal_child_lease_for_workspace(&record.issue, &record.workspace) + .await + { + retry_records.push(record); + retry_records.extend(records.iter().skip(record_index + 1).cloned()); + self.pending_recovery = Some(retry_records); + return Err(error); + } + let workspace_has_active_lease = + match self.workspace_has_active_lease(&record.workspace).await { + Ok(has_active_lease) => has_active_lease, + Err(error) => { + retry_records.push(record); + retry_records.extend(records.iter().skip(record_index + 1).cloned()); + self.pending_recovery = Some(retry_records); + return Err(error); + } + }; + if workspace_has_active_lease { + let mut execution = IssueExecution::new(record.issue.clone(), observed_at); + execution.attach_workspace(record.workspace.clone())?; + self.insert_execution( + issue_id.clone(), + execution.release(observed_at, ReleaseReason::TrackerTerminal, None)?, + ); + if record.had_in_flight_run + || (record.completed_run && !record.successful_run && !record.cancelled_run) + { + self.terminal_child_failure_ids.insert(issue_id.clone()); + } + retry_records.push(record); + continue; + } let retain_failed = !record.successful_run && !record.cancelled_run && self.retry_limit_reached(record.normal_retry_count) @@ -1599,21 +2520,50 @@ where } else { self.pending_recovery = Some(retry_records); } + if self.prune_recovered_memory_issue_ids() { + self.hierarchy_state_dirty = true; + self.persist_orchestrator_state().await?; + } Ok(()) } + fn prune_recovered_memory_issue_ids(&mut self) -> bool { + let mut retained = self + .pending_recovery + .as_ref() + .into_iter() + .flatten() + .map(|record| record.issue.id.clone()) + .collect::>(); + retained.extend( + self.pending_retry_recovery + .as_ref() + .into_iter() + .flatten() + .map(|record| record.issue.id.clone()), + ); + retained.extend( + self.executions + .iter() + .filter(|(_, execution)| !matches!(execution.status(), SchedulerStatus::Released)) + .map(|(issue_id, _)| issue_id.clone()), + ); + let before = self.recovered_memory_issue_ids.len(); + self.recovered_memory_issue_ids + .retain(|issue_id| retained.contains(issue_id)); + self.recovered_memory_issue_ids.len() != before + } + async fn reconcile_tracker_state( &mut self, tracker_snapshot: &TrackerSnapshot, observed_at: TimestampMs, ) -> Result<(), SchedulerError> { + let durable_state_changed = self.reconcile_hierarchy_snapshots(tracker_snapshot)? + || self.reconcile_terminal_hierarchy_snapshots(tracker_snapshot)?; + self.fence_hierarchy_changed_runs(observed_at).await?; for tracker_issue in &tracker_snapshot.active { let normalized = normalize_tracker_issue(tracker_issue, &self.config)?; - if normalized.sub_issues.is_empty() { - self.parent_issue_ids.remove(&normalized.id); - } else { - self.parent_issue_ids.insert(normalized.id.clone()); - } let retry_cleanup_workspace = self .executions .get(&normalized.id) @@ -1621,6 +2571,7 @@ where .and_then(|execution| execution.workspace().cloned()); if let Some(workspace) = retry_cleanup_workspace && !self.workspace.retain_failed_workspaces() + && !self.workspace_has_active_lease(&workspace).await? { match self.workspace.cleanup_failed_workspace(&workspace).await { Ok(()) => { @@ -1818,6 +2769,9 @@ where } } + if durable_state_changed || self.hierarchy_state_dirty { + self.persist_orchestrator_state().await?; + } Ok(()) } @@ -1828,7 +2782,9 @@ where if execution.issue().state.category != IssueStateCategory::Active { return false; } - if self.parent_issue_ids.contains(&execution.issue().id) { + if execution.issue().sub_issues.is_empty() + && self.parent_issue_ids.contains(&execution.issue().id) + { return false; } match execution.status() { @@ -2607,7 +3563,8 @@ where detailed.push(detailed_issue); } - self.dispatch_ready_issues(&detailed, observed_at).await?; + self.dispatch_ready_issues(&detailed, observed_at, None) + .await?; if self.worker_metadata.len() >= usize::try_from(self.config.max_concurrent_agents).unwrap_or(usize::MAX) { @@ -2622,6 +3579,7 @@ where &mut self, active_issues: &[TrackerIssue], observed_at: TimestampMs, + reachable_child_edges: Option<&BTreeSet<(IssueId, IssueId)>>, ) -> Result<(), SchedulerError> { let ready = filter_issues_for_dispatch(active_issues.to_vec(), &self.config.terminal_state_set()); @@ -2633,6 +3591,7 @@ where } let mut pending_launches = Vec::new(); + let mut first_error = None; let mut planned_running_by_state: HashMap = HashMap::new(); for tracker_issue in ready { @@ -2641,9 +3600,6 @@ where } let normalized = normalize_tracker_issue(&tracker_issue, &self.config)?; - if !normalized.sub_issues.is_empty() || self.parent_issue_ids.contains(&normalized.id) { - continue; - } let issue_id = normalized.id.clone(); let should_dispatch = match self.executions.get(&issue_id) { Some(execution) => match execution.status() { @@ -2691,13 +3647,100 @@ where } } - let workspace = self + if self.reconcile_hierarchy_issue(&tracker_issue, None)? || self.hierarchy_state_dirty { + self.hierarchy_state_dirty = true; + self.persist_orchestrator_state().await?; + } + // Keep the known execution hydrated with the same detailed issue + // that just drove hierarchy reconciliation. The later known- + // candidate pass must not reintroduce an older child-edge list. + self.refresh_execution_issue(&issue_id, normalized.clone())?; + + if normalized.sub_issues.is_empty() && self.parent_issue_ids.contains(&normalized.id) { + continue; + } + + // Check the ancestor fence before parent eligibility can persist a + // dispatch intent and acquire leases. A nested parent may be + // reactivated while a higher parent is integrating retained + // evidence; leaving the intent behind would make the nested + // parent permanently undispatchable after the higher lease ends. + if self + .hierarchy_state + .has_active_dispatched_ancestor(&issue_id) + { + continue; + } + + if !normalized.sub_issues.is_empty() { + let parent_retry = self + .executions + .get(&issue_id) + .is_some_and(|execution| execution.status() == SchedulerStatus::RetryQueued); + if !self + .parent_dispatch_is_eligible( + &tracker_issue, + &normalized, + observed_at, + parent_retry, + reachable_child_edges, + ) + .await? + { + continue; + } + } + + if let Some(hierarchy_generation) = self + .hierarchy_state + .hierarchy + .values() + .filter(|snapshot| { + snapshot + .required_child_edges + .iter() + .any(|edge| edge.required && edge.child_id == issue_id) + }) + .map(|snapshot| snapshot.generation) + .next() + { + let previous_state = self.hierarchy_state.clone(); + self.hierarchy_state + .run_hierarchy_generations + .insert(issue_id.clone(), hierarchy_generation); + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + } + + let workspace = match self .workspace .ensure_workspace(&normalized, observed_at) .await - .map_err(|error| SchedulerError::Workspace { - detail: error.to_string(), - })?; + { + Ok(workspace) => workspace, + Err(error) => { + let error = self + .clear_parent_dispatch_intent_after_preparation_failure( + &issue_id, + SchedulerError::Workspace { + detail: error.to_string(), + }, + ) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + }; if let Some(normal_retry_count) = self .executions @@ -2709,15 +3752,38 @@ where // worker's start_run preparation writes the replacement run // manifest. A crash before start_workers must recover the // queued retry rather than an advanced, unqueued count. - self.workspace + if let Err(error) = self + .workspace .persist_retry_count(&workspace, normal_retry_count) .await - .map_err(|error| SchedulerError::Workspace { - detail: error.to_string(), - })?; + { + let error = self + .clear_parent_dispatch_intent_after_preparation_failure( + &issue_id, + SchedulerError::Workspace { + detail: error.to_string(), + }, + ) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } } - let worker_id = self.next_worker_id()?; + let worker_id = match self.next_worker_id() { + Ok(worker_id) => worker_id, + Err(error) => { + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + }; let previous_retry = self .executions .get(&issue_id) @@ -2739,14 +3805,59 @@ where .and_then(RepositoryBindingOutcome::resolved_binding) .cloned(), ); - let route = decide_issue_route(&normalized, &self.config)?; + let route = match decide_issue_route(&normalized, &self.config) { + Ok(route) => route, + Err(error) => { + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + }; let mut execution = self .remove_execution(&issue_id) .unwrap_or_else(|| IssueExecution::new(normalized.clone(), observed_at)); - execution.refresh_issue(normalized.clone())?; - execution.attach_workspace(workspace.clone())?; - execution = execution.claim(run.clone())?; + if let Err(error) = execution.refresh_issue(normalized.clone()) { + self.insert_execution(issue_id.clone(), execution); + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error.into()) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + if let Err(error) = execution.attach_workspace(workspace.clone()) { + self.insert_execution(issue_id.clone(), execution); + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error.into()) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + let execution_before_claim = execution.clone(); + execution = match execution.claim(run.clone()) { + Ok(execution) => execution, + Err(error) => { + self.insert_execution(issue_id.clone(), execution_before_claim); + let error = self + .clear_parent_dispatch_intent_after_preparation_failure( + &issue_id, + error.into(), + ) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + }; let claimed_run = execution .current_run() .cloned() @@ -2776,13 +3887,13 @@ where ) .await; - let mut first_error = None; for ((issue_id, mut execution, claimed_run, start_request), result) in pending_launches.into_iter().zip(start_results) { match result { Ok(launch) => { self.recovered_memory_issue_ids.remove(&issue_id); + let started_at = launch.started_at.unwrap_or(observed_at); execution = execution.start_running( observed_at, effective_stall_timeout(self.config.stall_timeout_ms), @@ -2796,6 +3907,61 @@ where Some(start_request.route.harness_kind), ), ); + let run_start_changed = self + .hierarchy_state + .run_started_at_by_issue + .insert(issue_id.clone(), started_at) + .is_none_or(|previous| previous != started_at); + self.hierarchy_state + .terminal_orchestrator_issues + .remove(&issue_id); + if !execution.issue().sub_issues.is_empty() + && self.hierarchy_state.hierarchy.contains_key(&issue_id) + { + let nested_child_ids = self + .hierarchy_state + .hierarchy + .get(&issue_id) + .map(|snapshot| { + snapshot + .required_child_edges + .iter() + .filter(|edge| { + edge.required + && self + .hierarchy_state + .hierarchy + .contains_key(&edge.child_id) + }) + .map(|edge| edge.child_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(&issue_id) { + snapshot.mark_dispatched(); + } + self.hierarchy_state + .release_leaf_leases_for_parent(&issue_id, observed_at.as_u64()); + self.hierarchy_state.release_ancestor_leases_for_children( + &nested_child_ids, + observed_at.as_u64(), + ); + if let Err(error) = self.persist_orchestrator_state().await + && first_error.is_none() + { + self.hierarchy_state_dirty = true; + first_error = Some(error); + } + } + if run_start_changed + && (execution.issue().sub_issues.is_empty() + || !self.hierarchy_state.hierarchy.contains_key(&issue_id)) + && let Err(error) = self.persist_orchestrator_state().await + && first_error.is_none() + { + self.hierarchy_state_dirty = true; + first_error = Some(error); + } if let Err(error) = self.workspace.clear_retry_pending(&issue_id).await { if first_error.is_none() { first_error = Some(SchedulerError::Workspace { @@ -2809,6 +3975,16 @@ where Err(error) => { let detail = error.to_string(); warn!(issue_id = %issue_id, error = %detail, "failed to launch scheduler worker"); + if !execution.issue().sub_issues.is_empty() + && let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(&issue_id) + { + snapshot.clear_dispatch_intent(); + if let Err(persist_error) = self.persist_orchestrator_state().await + && first_error.is_none() + { + first_error = Some(persist_error); + } + } let outcome = WorkerOutcomeRecord::from_run( &claimed_run, WorkerOutcomeKind::Failed, @@ -2833,6 +4009,33 @@ where first_error.map_or(Ok(()), Err) } + async fn clear_parent_dispatch_intent_after_preparation_failure( + &mut self, + issue_id: &IssueId, + error: SchedulerError, + ) -> SchedulerError { + let should_clear = self + .hierarchy_state + .hierarchy + .get(issue_id) + .is_some_and(HierarchySnapshot::dispatch_intended); + if !should_clear { + return error; + } + if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(issue_id) { + snapshot.clear_dispatch_intent(); + } + if let Err(persist_error) = self.persist_orchestrator_state().await { + warn!( + issue = %issue_id, + error = %persist_error, + "failed to clear parent dispatch intent after launch preparation failure" + ); + return persist_error; + } + error + } + async fn apply_worker_updates( &mut self, updates: Vec, @@ -2875,19 +4078,41 @@ where }; let finished_at = outcome.finished_at; let original_execution = execution.clone(); + let finished_outcome = outcome.clone(); let execution = match self .resolve_finished_execution(execution, outcome, finished_at) .await { Ok(execution) => execution, Err(error) => { - self.insert_execution(issue_id.clone(), original_execution); + let mut retained_execution = original_execution; + retained_execution.retain_worker_outcome(finished_outcome.clone()); + self.insert_execution(issue_id.clone(), retained_execution.clone()); + self.pending_finished_updates + .insert(issue_id.clone(), (retained_execution, finished_outcome)); if first_error.is_none() { first_error = Some(error); } continue; } }; + if let Err(error) = self + .rebind_finished_child_hierarchy_generation(&issue_id) + .await + { + // The outcome has already been applied by + // resolve_finished_execution. If rebinding its + // hierarchy generation cannot be durably persisted, + // retry the original running execution so the next + // flush applies the outcome exactly once. + self.insert_execution(issue_id.clone(), original_execution.clone()); + self.pending_finished_updates + .insert(issue_id.clone(), (original_execution, finished_outcome)); + if first_error.is_none() { + first_error = Some(error); + } + continue; + } self.insert_execution(issue_id.clone(), execution); if let Err(error) = self.persist_retry_if_queued(&issue_id).await && first_error.is_none() @@ -2939,6 +4164,47 @@ where first_error.map_or(Ok(()), Err) } + async fn rebind_finished_child_hierarchy_generation( + &mut self, + issue_id: &IssueId, + ) -> Result<(), SchedulerError> { + let Some(generation) = self + .hierarchy_state + .hierarchy + .values() + .filter(|snapshot| { + snapshot + .required_child_edges + .iter() + .any(|edge| edge.required && edge.child_id == *issue_id) + }) + .map(|snapshot| snapshot.generation) + .next() + else { + return Ok(()); + }; + if self + .hierarchy_state + .run_hierarchy_generations + .get(issue_id) + .is_none_or(|current| *current == generation) + { + return Ok(()); + } + + let previous_state = self.hierarchy_state.clone(); + self.hierarchy_state + .run_hierarchy_generations + .insert(issue_id.clone(), generation); + self.hierarchy_state + .rebind_leaf_leases(&BTreeSet::from([issue_id.clone()]), generation); + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + Ok(()) + } + async fn handle_stalls(&mut self, observed_at: TimestampMs) -> Result<(), SchedulerError> { if self.config.stall_timeout_ms.is_none() { return Ok(()); @@ -3123,11 +4389,38 @@ where } else { reason }; + let successful_terminal_outcome = reason == ReleaseReason::Completed + || (reason == ReleaseReason::TrackerTerminal + && execution + .last_worker_outcome() + .is_some_and(|outcome| outcome.outcome == WorkerOutcomeKind::Succeeded)); + if successful_terminal_outcome { + self.record_terminal_orchestrator_success(&issue_id).await?; + } + if cleanup_terminal && let Err(error) = self.retain_terminal_child_lease(&execution).await { + self.insert_execution(issue_id, execution); + return Err(error); + } + if matches!( + reason, + ReleaseReason::TrackerTerminal | ReleaseReason::Completed + ) && let Err(error) = self + .release_parent_leases_after_finalization( + &issue_id, + execution.issue().parent_id.as_ref(), + observed_at, + ) + .await + { + self.insert_execution(issue_id, execution); + return Err(error); + } let mut retry_cleanup_succeeded = retain_failed; if cleanup_terminal && remote_stopped && !retain_failed && let Some(workspace) = execution.workspace().cloned() + && !self.workspace_has_active_lease(&workspace).await? { match if cleanup_reason == ReleaseReason::RetryExhausted { self.workspace.cleanup_failed_workspace(&workspace).await @@ -3355,6 +4648,29 @@ where reason, ReleaseReason::TrackerTerminal | ReleaseReason::RetryExhausted ); + if cleanup_terminal { + self.retain_terminal_child_lease(&execution).await?; + } + let successful_terminal_outcome = reason == ReleaseReason::Completed + || (reason == ReleaseReason::TrackerTerminal + && outcome + .as_ref() + .is_some_and(|outcome| outcome.outcome == WorkerOutcomeKind::Succeeded)); + if successful_terminal_outcome { + self.record_terminal_orchestrator_success(&execution.issue().id) + .await?; + } + if matches!( + reason, + ReleaseReason::TrackerTerminal | ReleaseReason::Completed + ) { + self.release_parent_leases_after_finalization( + &execution.issue().id, + execution.issue().parent_id.as_ref(), + observed_at, + ) + .await?; + } if reason == ReleaseReason::RetryExhausted { let normal_retry_count = execution .current_run() @@ -3373,6 +4689,7 @@ where if cleanup_terminal && !retain_failed && let Some(workspace) = execution.workspace().cloned() + && !self.workspace_has_active_lease(&workspace).await? { let cleanup = self.workspace.cleanup_failed_workspace(&workspace).await; match cleanup { @@ -3394,6 +4711,7 @@ where if cleanup_terminal && !retain_failed && let Some(workspace) = execution.workspace().cloned() + && !self.workspace_has_active_lease(&workspace).await? { let cleanup = if reason == ReleaseReason::RetryExhausted { self.workspace.cleanup_failed_workspace(&workspace).await @@ -3414,6 +4732,51 @@ where Ok(execution) } + async fn record_terminal_orchestrator_success( + &mut self, + issue_id: &IssueId, + ) -> Result<(), SchedulerError> { + if self + .hierarchy_state + .terminal_orchestrator_issues + .insert(issue_id.clone()) + && let Err(error) = self.persist_orchestrator_state().await + { + self.hierarchy_state + .terminal_orchestrator_issues + .remove(issue_id); + return Err(error); + } + Ok(()) + } + + async fn release_parent_leases_after_finalization( + &mut self, + parent_id: &IssueId, + higher_parent_id: Option<&IssueId>, + released_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let mut next_state = self.hierarchy_state.clone(); + let has_higher_parent = + higher_parent_id.is_some() || next_state.has_ancestor_edge(parent_id); + // Tracker parent links can change before the next hierarchy refresh; + // defer descendant release until that reachability-aware pass. + let released = if has_higher_parent { + next_state.release_parent_leases_preserving_ancestor(parent_id, released_at.as_u64()) + } else { + next_state.release_parent_leases(parent_id, released_at.as_u64()) + }; + if !released { + return Ok(()); + } + let previous_state = std::mem::replace(&mut self.hierarchy_state, next_state); + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + Ok(()) + } + async fn persist_retry_exhaustion( &mut self, issue: &NormalizedIssue, @@ -3505,6 +4868,490 @@ where first_error.map_or(Ok(()), |detail| Err(SchedulerError::Workspace { detail })) } + async fn flush_pending_finished_updates(&mut self) -> Result<(), SchedulerError> { + let pending = std::mem::take(&mut self.pending_finished_updates); + let mut first_error = None; + for (issue_id, (execution, outcome)) in pending { + let finished_at = outcome.finished_at; + let retry_execution = execution.clone(); + match self + .resolve_finished_execution(execution, outcome.clone(), finished_at) + .await + { + Ok(execution) => { + if let Err(error) = self + .rebind_finished_child_hierarchy_generation(&issue_id) + .await + { + self.insert_execution(issue_id.clone(), retry_execution.clone()); + self.pending_finished_updates + .insert(issue_id.clone(), (retry_execution, outcome)); + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + self.insert_execution(issue_id.clone(), execution); + if let Err(error) = self.persist_retry_if_queued(&issue_id).await + && first_error.is_none() + { + first_error = Some(error); + } + } + Err(error) => { + self.insert_execution(issue_id.clone(), retry_execution.clone()); + self.pending_finished_updates + .insert(issue_id.clone(), (retry_execution, outcome)); + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + first_error.map_or(Ok(()), Err) + } + + async fn parent_dispatch_is_eligible( + &mut self, + tracker_issue: &TrackerIssue, + normalized: &NormalizedIssue, + observed_at: TimestampMs, + allow_retry: bool, + reachable_child_edges: Option<&BTreeSet<(IssueId, IssueId)>>, + ) -> Result { + if self.linear_cooldown_active(observed_at) { + return Ok(false); + } + let Some(snapshot) = self.hierarchy_state.hierarchy.get(&normalized.id).cloned() else { + return Ok(false); + }; + if snapshot.blocked_reason.is_some() { + return Ok(false); + } + // Summary discovery and known-candidate reconciliation do not carry + // the complete tracker edge set. Keep parents deferred until a full + // observation can release canceled-subtree evidence safely. + if reachable_child_edges.is_none() { + return Ok(false); + } + if snapshot.dispatch_claimed() && !allow_retry { + return Ok(allow_retry); + } + if snapshot.dispatch_intended() && !allow_retry { + return Ok(false); + } + if !allow_retry + && self + .parent_eligibility_checked_at + .get(&normalized.id) + .is_some_and(|checked_at| { + checked_at + .as_u64() + .saturating_add(DISPATCH_DISCOVERY_INTERVAL_MS) + > observed_at.as_u64() + }) + { + return Ok(false); + } + self.parent_eligibility_checked_at + .insert(normalized.id.clone(), observed_at); + let eligibility_timeout = + parent_eligibility_timeout(self.parent_eligibility_work_units(&normalized.id)); + let mut evidence = match timeout( + eligibility_timeout, + self.tracker.parent_eligibility(tracker_issue, &snapshot), + ) + .await + { + Err(_) => { + warn!( + parent = %normalized.identifier, + timeout = ?eligibility_timeout, + "parent eligibility provider lookup timed out; keeping parent blocked" + ); + self.record_parent_eligibility_block( + &normalized.id, + HierarchyBlockedReason::UnresolvedFailure( + "parent eligibility provider lookup timed out".to_owned(), + ), + ) + .await?; + return Ok(false); + } + Ok(Ok(evidence)) => evidence, + Ok(Err(error)) => { + self.set_linear_cooldown_from_tracker_error(&error, observed_at); + warn!( + parent = %normalized.identifier, + error = %error, + "parent eligibility provider lookup failed; keeping parent blocked" + ); + self.record_parent_eligibility_block( + &normalized.id, + HierarchyBlockedReason::UnresolvedFailure(error.to_string()), + ) + .await?; + return Ok(false); + } + }; + let mut released_canceled_subtree_holds = false; + for child in &mut evidence.children { + // A current execution supersedes an older durable success receipt. + // Provider terminal flags never authorize parent admission. + child.orchestrator_terminal = self.executions.get(&child.child_id).map_or_else( + || { + self.hierarchy_state + .terminal_orchestrator_issues + .contains(&child.child_id) + }, + |execution| { + matches!( + execution.state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::TrackerTerminal | ReleaseReason::Completed, + .. + } + ) + }, + ); + if !child.merge_required { + if !child.orchestrator_terminal { + child.unresolved_failure = + Some("child orchestrator outcome is not durably terminal".to_owned()); + continue; + } + if let Some(reachable_child_edges) = reachable_child_edges { + released_canceled_subtree_holds |= self + .hierarchy_state + .release_subtree_evidence_for_undispatched_parent_with_reachability( + &child.child_id, + Some(reachable_child_edges), + observed_at.as_u64(), + ); + } + child.resource = None; + child.resources.clear(); + continue; + } + if self.terminal_child_failure_ids.contains(&child.child_id) + || self + .executions + .get(&child.child_id) + .is_some_and(|execution| { + execution.retry().is_some() + || execution.last_worker_outcome().is_some_and(|outcome| { + matches!( + outcome.outcome, + WorkerOutcomeKind::Failed + | WorkerOutcomeKind::TimedOut + | WorkerOutcomeKind::Stalled + | WorkerOutcomeKind::Detached + | WorkerOutcomeKind::CancelFailed + ) + }) + }) + { + child.orchestrator_terminal = false; + child.unresolved_failure = + Some("child has an unresolved worker failure or retry".to_owned()); + continue; + } + let direct_provider_evidence_is_stale = + child.provider_evidence_at.is_some_and(|evidence_at| { + !self.hierarchy_state.hierarchy.contains_key(&child.child_id) + && self.child_run_started_after(&child.child_id, evidence_at) + }); + let descendant_provider_evidence_is_stale = + child.provider_evidence_by_issue.iter().any(|boundary| { + self.child_run_started_after(&boundary.issue_id, boundary.evidence_at) + }); + if child.provider_merge_confirmed + && (direct_provider_evidence_is_stale || descendant_provider_evidence_is_stale) + { + child.orchestrator_terminal = false; + child.unresolved_failure = + Some("provider merge evidence predates the current child run".to_owned()); + continue; + } + if child.resource.is_none() { + let descendant_resources = self + .hierarchy_state + .descendant_resources_for(&child.child_id); + if !descendant_resources.is_empty() { + child.resource = descendant_resources.first().cloned(); + child.resources = descendant_resources; + } else { + let retry_resources = self + .hierarchy_state + .ancestor_resources_for_child(&normalized.id, &child.child_id); + let expected_owner = super::LeaseOwner::leaf_worker(&child.child_id); + let leaf_resource = self + .hierarchy_state + .leases + .iter() + .find(|lease| { + lease.active() + && lease.kind == super::LeaseKind::LeafWorker + && lease.hierarchy_generation == snapshot.generation + && lease.owner == expected_owner + && lease.resource.issue_id == child.child_id + }) + .map(|lease| lease.resource.clone()); + if retry_resources.is_empty() { + child.resource = leaf_resource; + } else { + let mut resources = retry_resources; + if let Some(leaf_resource) = leaf_resource + && !resources.contains(&leaf_resource) + { + resources.push(leaf_resource); + } + child.resource = resources.first().cloned(); + child.resources = resources; + } + } + } + if child.resources.is_empty() { + child.resources = child.resource.clone().into_iter().collect(); + } + } + if released_canceled_subtree_holds { + self.persist_orchestrator_state().await?; + } + if let Err(reason) = evidence.eligible_for(&snapshot) { + self.record_parent_eligibility_block(&normalized.id, reason) + .await?; + return Ok(false); + } + if evidence.children.iter().any(|child| { + child.resources().into_iter().any(|resource| { + if self.hierarchy_state.leases.iter().any(|lease| { + lease.active() + && lease.kind == super::LeaseKind::AncestorIntegration + && lease.owner == super::LeaseOwner::ancestor(&normalized.id) + && lease.hierarchy_generation == snapshot.generation + && lease.resource == *resource + }) { + return false; + } + if self.hierarchy_state.hierarchy.contains_key(&child.child_id) { + return !self.hierarchy_state.leases.iter().any(|lease| { + lease.active() + && lease.kind == super::LeaseKind::AncestorIntegration + && lease.owner == super::LeaseOwner::ancestor(&child.child_id) + && lease.resource == *resource + }); + } + let expected_owner = super::LeaseOwner::leaf_worker(&resource.issue_id); + !self.hierarchy_state.leases.iter().any(|lease| { + lease.active() + && lease.kind == super::LeaseKind::LeafWorker + && lease.hierarchy_generation == snapshot.generation + && lease.owner == expected_owner + && lease.resource == *resource + }) + }) + }) { + self.record_parent_eligibility_block( + &normalized.id, + HierarchyBlockedReason::MissingCheckoutEvidence, + ) + .await?; + return Ok(false); + } + + self.clear_parent_eligibility_block(&normalized.id).await?; + + let mut next_state = self.hierarchy_state.clone(); + let Some(next_snapshot) = next_state.hierarchy.get_mut(&normalized.id) else { + return Ok(false); + }; + let mut required_merge_commits = BTreeSet::new(); + for commit in evidence.children.iter().flat_map(|child| { + if child.merge_result_commits_by_repository.is_empty() { + child + .merge_result_commit + .iter() + .chain(child.merge_result_commits.iter()) + .map(|commit| super::RequiredMergeCommit { + repository_id: child.merge_repository_id.clone(), + commit: commit.clone(), + }) + .collect::>() + } else { + child.merge_result_commits_by_repository.clone() + } + }) { + if !commit.commit.trim().is_empty() { + required_merge_commits.insert(commit); + } + } + next_snapshot.dispatch_required_merge_commits = + required_merge_commits.into_iter().collect(); + if next_snapshot.freeze().is_err() { + return Ok(false); + } + next_snapshot.mark_dispatch_intent(); + let mut required_leases = evidence.integration_leases(&normalized.id, observed_at.as_u64()); + required_leases.extend(evidence.children.iter().flat_map(|child| { + child + .resources() + .into_iter() + .cloned() + .map(|resource| LeaseRecord { + kind: super::LeaseKind::Review, + resource, + owner: super::LeaseOwner::review_for_parent(&normalized.id, &child.child_id), + hierarchy_generation: evidence.hierarchy_generation, + acquired_at: observed_at.as_u64(), + expires_at: None, + released_at: None, + }) + })); + next_state + .acquire_leases(required_leases) + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })?; + let previous_state = std::mem::replace(&mut self.hierarchy_state, next_state); + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + Ok(true) + } + + fn child_run_started_after(&self, issue_id: &IssueId, evidence_at: TimestampMs) -> bool { + self.hierarchy_state + .run_started_at_by_issue + .get(issue_id) + .copied() + .or_else(|| { + self.executions + .get(issue_id) + .and_then(|execution| execution.last_worker_outcome()) + .map(|outcome| outcome.started_at) + }) + .is_some_and(|started_at| evidence_at < started_at) + } + + async fn record_parent_eligibility_block( + &mut self, + parent_id: &IssueId, + reason: HierarchyBlockedReason, + ) -> Result<(), SchedulerError> { + let changed = self + .hierarchy_state + .hierarchy + .get_mut(parent_id) + .is_some_and(|snapshot| { + if snapshot.blocked_reason.is_some() + || snapshot.eligibility_blocked_reason.as_ref() == Some(&reason) + { + false + } else { + snapshot.eligibility_blocked_reason = Some(reason); + true + } + }); + if changed { + self.persist_orchestrator_state().await?; + } + Ok(()) + } + + async fn clear_parent_eligibility_block( + &mut self, + parent_id: &IssueId, + ) -> Result<(), SchedulerError> { + let changed = self + .hierarchy_state + .hierarchy + .get_mut(parent_id) + .is_some_and(|snapshot| snapshot.eligibility_blocked_reason.take().is_some()); + if changed { + self.persist_orchestrator_state().await?; + } + Ok(()) + } + + async fn hydrate_parent_hierarchy_for_terminal_child( + &mut self, + issue: &NormalizedIssue, + ) -> Result<(), SchedulerError> { + let Some(parent_id) = issue.parent_id.as_ref() else { + return Ok(()); + }; + if self.hierarchy_state.has_ancestor_edge(&issue.id) { + return Ok(()); + } + let parent = self + .tracker + .issue_by_id(parent_id.as_str()) + .await + .map_err(|error| SchedulerError::Tracker { + detail: format!("failed to hydrate terminal child parent {parent_id}: {error}"), + })? + .ok_or_else(|| SchedulerError::Tracker { + detail: format!("terminal child parent {parent_id} was not found"), + })?; + if self.reconcile_hierarchy_issue(&parent, None)? { + self.hierarchy_state_dirty = true; + self.persist_orchestrator_state().await?; + } + if self.hierarchy_state.has_ancestor_edge(&issue.id) { + Ok(()) + } else { + Err(SchedulerError::Tracker { + detail: format!( + "terminal child {issue_id} parent {parent_id} did not contain the child edge", + issue_id = issue.id + ), + }) + } + } + + fn parent_eligibility_work_units(&self, parent_id: &IssueId) -> usize { + self.hierarchy_provider_work_units(parent_id, &mut HashSet::new()) + } + + fn hierarchy_provider_work_units( + &self, + issue_id: &IssueId, + visiting: &mut HashSet, + ) -> usize { + if !visiting.insert(issue_id.clone()) { + return 1; + } + let child_ids = self + .hierarchy_state + .hierarchy + .get(issue_id) + .map(|snapshot| { + snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| edge.child_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + let work_units = if child_ids.is_empty() { + 1 + } else { + let descendant_work_units = child_ids + .iter() + .map(|child_id| self.hierarchy_provider_work_units(child_id, visiting)) + .sum::(); + let has_nested_parent = child_ids + .iter() + .any(|child_id| self.hierarchy_state.hierarchy.contains_key(child_id)); + descendant_work_units + usize::from(has_nested_parent) + }; + visiting.remove(issue_id); + work_units.max(1) + } + async fn persist_retry_if_queued(&mut self, issue_id: &IssueId) -> Result<(), SchedulerError> { let Some((retry, workspace)) = self.executions.get(issue_id).and_then(|execution| { execution @@ -3542,11 +5389,38 @@ where return Ok(()); } + if !self.durable_state_loaded { + if let Some(raw) = self + .workspace + .load_orchestrator_state() + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + { + self.hierarchy_state = + serde_json::from_value(raw).map_err(|error| SchedulerError::Workspace { + detail: format!("invalid durable hierarchy state: {error}"), + })?; + self.hierarchy_state + .validate() + .map_err(|detail| SchedulerError::Workspace { detail })?; + } + self.durable_state_loaded = true; + } + let recoveries = self.workspace.recover_workspaces().await.map_err(|error| { SchedulerError::Workspace { detail: error.to_string(), } })?; + let recovered_run_started_at = + self.workspace + .recovered_run_started_at() + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })?; let retry_exhaustion = self.workspace .recover_retry_exhaustion() @@ -3565,12 +5439,240 @@ where .extend(recoveries.iter().map(|record| record.issue.id.clone())); self.recovered_memory_issue_ids .extend(retry_pending.iter().map(|record| record.issue.id.clone())); + self.recovered_memory_issue_ids + .extend(recovered_run_started_at.keys().cloned()); + if !recovered_run_started_at.is_empty() { + self.hierarchy_state + .run_started_at_by_issue + .extend(recovered_run_started_at); + self.persist_orchestrator_state().await?; + } self.pending_recovery = Some(recoveries); self.pending_retry_exhaustion = Some(retry_exhaustion); self.pending_retry_recovery = Some(retry_pending); Ok(()) } + async fn persist_orchestrator_state(&mut self) -> Result<(), SchedulerError> { + let mut retained_issue_ids = self + .executions + .iter() + .filter(|(_, execution)| { + execution.workspace().is_some() + || matches!( + execution.status(), + SchedulerStatus::Claimed + | SchedulerStatus::Running + | SchedulerStatus::RetryQueued + ) + }) + .map(|(issue_id, _)| issue_id.clone()) + .collect::>(); + retained_issue_ids.extend(self.recovered_memory_issue_ids.iter().cloned()); + self.hierarchy_state + .prune_obsolete_run_boundaries(&retained_issue_ids); + let state = serde_json::to_value(&self.hierarchy_state).map_err(|error| { + SchedulerError::Workspace { + detail: format!("failed to encode durable hierarchy state: {error}"), + } + })?; + self.workspace + .persist_orchestrator_state(&state) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })?; + self.hierarchy_state_dirty = false; + Ok(()) + } + + async fn retain_terminal_child_lease( + &mut self, + execution: &IssueExecution, + ) -> Result { + let Some(workspace) = execution.workspace() else { + return Ok(false); + }; + self.retain_terminal_child_lease_for_workspace(execution.issue(), workspace) + .await + } + + async fn parent_is_terminal_and_undispatched(&mut self, parent_id: &IssueId) -> bool { + let parent_identifier = parent_id.as_str().to_owned(); + let Ok(states) = self + .tracker + .issue_states_by_ids(std::slice::from_ref(&parent_identifier)) + .await + else { + // A failed refresh must retain evidence. Cleanup can retry after + // the provider confirms whether the parent is still terminal. + return false; + }; + let parent_terminal = states.iter().any(|snapshot| { + snapshot.id == parent_identifier + && state_category_from_name(&snapshot.state.name, &self.config) + == IssueStateCategory::Terminal + }); + let undispatched = parent_terminal + && self + .hierarchy_state + .hierarchy + .get(parent_id) + .is_some_and(|snapshot| !snapshot.has_dispatched_execution_fence()); + if !undispatched { + self.terminal_undispatched_parent_ids.remove(parent_id); + } + undispatched + } + + async fn retain_terminal_child_lease_for_workspace( + &mut self, + issue: &NormalizedIssue, + workspace: &WorkspaceRecord, + ) -> Result { + self.hydrate_parent_hierarchy_for_terminal_child(issue) + .await?; + if !self.hierarchy_state.has_ancestor_edge(&issue.id) { + return Ok(false); + } + if self + .hierarchy_state + .has_active_dispatched_ancestor(&issue.id) + { + return Ok(false); + } + if let Some(parent_id) = issue.parent_id.as_ref() + && self.parent_is_terminal_and_undispatched(parent_id).await + { + if self + .hierarchy_state + .release_subtree_evidence_for_undispatched_parent(parent_id, current_epoch_millis()) + { + self.persist_orchestrator_state().await?; + } + return Ok(false); + } + let Some(resource) = self + .workspace + .workspace_lease_resource(issue, workspace) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + else { + return Ok(false); + }; + if self.hierarchy_state.leases.iter().any(|lease| { + lease.active() + && lease.resource == resource + && lease.kind != super::LeaseKind::LeafWorker + }) { + return Ok(false); + } + let issue_id = issue.id.clone(); + let leaf_owner = super::LeaseOwner::leaf_worker(&issue_id); + let hierarchy_generation = self + .hierarchy_state + .run_hierarchy_generations + .get(&issue_id) + .copied() + .or_else(|| { + self.hierarchy_state + .hierarchy + .values() + .filter(|snapshot| { + snapshot + .required_child_edges + .iter() + .any(|edge| edge.required && edge.child_id == issue_id) + }) + .map(|snapshot| snapshot.generation) + .next() + }) + // A terminal child can finish before its parent enters the + // scheduler's configured scan. Seed the first snapshot generation + // so retained evidence can be consumed when that parent arrives. + .unwrap_or(1); + if self.hierarchy_state.leases.iter().any(|lease| { + lease.active() + && lease.kind == super::LeaseKind::LeafWorker + && lease.owner == leaf_owner + && lease.resource == resource + && lease.hierarchy_generation == hierarchy_generation + }) { + return Ok(true); + } + let acquired_at = current_epoch_millis(); + let mut next_state = self.hierarchy_state.clone(); + for lease in &mut next_state.leases { + if lease.active() + && lease.kind == super::LeaseKind::LeafWorker + && lease.owner == leaf_owner + { + lease.released_at = Some(acquired_at); + } + } + next_state + .acquire_leases(vec![LeaseRecord { + kind: super::LeaseKind::LeafWorker, + resource: resource.clone(), + owner: leaf_owner, + hierarchy_generation, + acquired_at, + expires_at: None, + released_at: None, + }]) + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })?; + let previous_state = std::mem::replace(&mut self.hierarchy_state, next_state); + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + Ok(true) + } + + async fn workspace_has_active_lease( + &mut self, + workspace: &WorkspaceRecord, + ) -> Result { + self.workspace + .workspace_has_active_lease(workspace) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + }) + } + + async fn release_stale_binding_leases( + &mut self, + issue: &NormalizedIssue, + workspace: &WorkspaceRecord, + released_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let Some(resource) = self + .workspace + .workspace_lease_resource(issue, workspace) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + else { + return Ok(()); + }; + let mut next_state = self.hierarchy_state.clone(); + if !next_state.release_resource_leases(&resource, released_at.as_u64()) { + return Ok(()); + } + let previous_state = std::mem::replace(&mut self.hierarchy_state, next_state); + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + Ok(()) + } + async fn flush_pending_retry_exhaustion_persistence(&mut self) -> Result<(), SchedulerError> { let pending = std::mem::take(&mut self.pending_retry_exhaustion_persistence); let mut first_error = None; @@ -3605,6 +5707,18 @@ where else { return; }; + match self.workspace_has_active_lease(&workspace).await { + Ok(true) => return, + Ok(false) => {} + Err(error) => { + tracing::warn!( + issue = %issue_id, + %error, + "unable to check durable lease before retry-exhausted cleanup" + ); + return; + } + } match self.workspace.cleanup_failed_workspace(&workspace).await { Ok(()) => { if let Some(execution) = self.executions.get_mut(issue_id) { @@ -3641,6 +5755,14 @@ where } fn insert_execution(&mut self, issue_id: IssueId, execution: IssueExecution) { + // Reopened/recovered work invalidates old success before launch + // preparation can temporarily remove the execution from this map. + if execution.status() != SchedulerStatus::Released { + self.hierarchy_state_dirty |= self + .hierarchy_state + .terminal_orchestrator_issues + .remove(&issue_id); + } let current_key = running_state_key_for_execution(&execution); if let Some(previous) = self.executions.insert(issue_id, execution) { self.decrement_running_count(&previous); @@ -3690,6 +5812,7 @@ struct TrackerSnapshot { active: Vec, active_index: HashMap, terminal_state_by_id: HashMap, + terminal: Vec, state_by_id: HashMap, } @@ -3912,6 +6035,7 @@ fn normalize_tracker_issue( state: issue_state_from_name(&issue.state, config), branch_name: issue.branch_name.clone(), pr_url: issue.pr_url.clone(), + pr_urls: issue.pr_urls.clone(), url: Some(issue.url.clone()), labels: issue.labels.clone(), project_id: issue.project_id.clone(), @@ -3964,6 +6088,7 @@ fn minimal_issue_from_state_snapshot( state: issue_state_from_name(&snapshot.state.name, config), branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: snapshot.labels.clone(), project_id: snapshot.project_id.clone(), @@ -4122,6 +6247,16 @@ fn matches_state_name(name: &str, states: &[String]) -> bool { .any(|state| normalized_state_name(state) == normalized) } +fn is_canceled_tracker_issue(issue: &TrackerIssue, canceled_states: &[String]) -> bool { + matches!(issue.state_kind, TrackerIssueStateKind::Canceled) + || canceled_states.iter().any(|configured_state| { + configured_state.to_ascii_lowercase().contains("cancel") + && configured_state + .trim() + .eq_ignore_ascii_case(issue.state.trim()) + }) +} + fn running_state_key_for_execution(execution: &IssueExecution) -> Option { (execution.status() == SchedulerStatus::Running) .then(|| normalized_state_name(&execution.issue().state.name)) @@ -4164,6 +6299,7 @@ fn tracker_issue_from_normalized(issue: &NormalizedIssue) -> TrackerIssue { state_kind: tracker_state_kind_from_issue_state(&issue.state), branch_name: issue.branch_name.clone(), pr_url: issue.pr_url.clone(), + pr_urls: issue.pr_urls.clone(), labels: issue.labels.clone(), project_id: issue.project_id.clone(), project_slug: issue.project_slug.clone(), @@ -4195,6 +6331,7 @@ fn tracker_issue_from_normalized(issue: &NormalizedIssue) -> TrackerIssue { title: None, url: None, state: child.state.clone(), + state_kind: tracker_state_kind_from_name(&child.state), }) .collect(), created_at: timestamp_to_datetime(issue.created_at), @@ -4321,10 +6458,29 @@ fn current_epoch_millis() -> u64 { .as_millis() as u64 } +fn conversation_id_suffix(value: &str) -> &str { + value.get(value.len().saturating_sub(8)..).unwrap_or(value) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn conversation_suffix_matches_gateway_alias_shape() { + assert_eq!(conversation_id_suffix("conv-123456789"), "23456789"); + assert_eq!(conversation_id_suffix("short"), "short"); + } + + #[test] + fn canceled_tracker_children_are_not_reachable_for_lease_release() { + let mut issue = tracker_issue_from_normalized(&issue_with_project(None, None)); + issue.state = "Canceled".to_owned(); + issue.state_kind = TrackerIssueStateKind::Canceled; + + assert!(is_canceled_tracker_issue(&issue, &["Canceled".to_owned()])); + } + fn issue_with_project(project_id: Option<&str>, project_slug: Option<&str>) -> NormalizedIssue { NormalizedIssue { id: IssueId::new("issue-project-drift").expect("issue id should be valid"), @@ -4339,6 +6495,7 @@ mod tests { }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: None, labels: Vec::new(), project_id: project_id.map(str::to_owned), @@ -4353,6 +6510,23 @@ mod tests { } } + #[test] + fn parent_eligibility_timeout_scales_by_provider_batches() { + assert_eq!(parent_eligibility_timeout(1), Duration::from_secs(30)); + assert_eq!( + parent_eligibility_timeout(PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY), + Duration::from_secs(30) + ); + assert_eq!( + parent_eligibility_timeout(PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY + 1), + Duration::from_secs(60) + ); + assert_eq!( + parent_eligibility_timeout(PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY * 2 + 1), + Duration::from_secs(90) + ); + } + #[test] fn recovered_scheduler_worker_ids_expose_their_allocator_ordinal() { let recovered = WorkerId::new("scheduler-worker-7").expect("worker id should be valid"); diff --git a/crates/opensymphony-orchestrator/src/selection.rs b/crates/opensymphony-orchestrator/src/selection.rs index 01fc5ae2e..658181e48 100644 --- a/crates/opensymphony-orchestrator/src/selection.rs +++ b/crates/opensymphony-orchestrator/src/selection.rs @@ -114,6 +114,7 @@ mod tests { title: None, url: None, state: state.to_string(), + state_kind: TrackerIssueStateKind::from_tracker_name(state), } } @@ -135,6 +136,7 @@ mod tests { state_kind: TrackerIssueStateKind::Started, branch_name: None, pr_url: None, + pr_urls: Vec::new(), labels: Vec::new(), project_id: None, project_slug: None, diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 3d2850431..867441253 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -8,18 +8,19 @@ use crate::opensymphony_domain::{ CanonicalRepositoryId, DurationMs, HarnessInterruptCommand, HarnessInterruptReason, HarnessInterruptStatus, RepositoryBinding, RepositoryBindingOutcome, RepositoryIdentity, RepositoryInventoryEntry, RepositoryRouting, RepositoryRoutingMode, SafeRemoteFingerprint, - TrackerErrorCategory, + TrackerErrorCategory, TrackerIssueRef, }; use crate::opensymphony_orchestrator::{ - ConversationId, ConversationMetadata, IssueId, IssueIdentifier, IssueRef, IssueState, - IssueStateCategory, NormalizedIssue, RecoveredRun, RecoveryRecord, ReleaseReason, RetryAttempt, - RetryEntry, RetryExhaustionRecord, RetryPendingRecord, RetryReason, RuntimeStreamState, - Scheduler, SchedulerConfig, SchedulerStatus, TimestampMs, TrackerBackend, TrackerIssue, - TrackerIssueBlocker, TrackerIssueState, TrackerIssueStateKind, TrackerIssueStateSnapshot, - TrackerIssueSummary, WorkerAbortReason, WorkerBackend, WorkerId, - WorkerInterruptAcknowledgement, WorkerLaunch, WorkerOutcomeKind, WorkerOutcomeRecord, - WorkerStartRequest, WorkerUpdate, WorkspaceBackend, WorkspaceKey, WorkspaceRecord, - decide_issue_route, + ChildEligibilityEvidence, ConversationId, ConversationMetadata, HierarchySnapshot, IssueId, + IssueIdentifier, IssueRef, IssueState, IssueStateCategory, LeaseKind, LeaseOwner, LeaseRecord, + LeaseResource, NormalizedIssue, ParentEligibilityEvidence, RecoveredRun, RecoveryRecord, + ReleaseReason, RequiredMergeCommit, RetryAttempt, RetryEntry, RetryExhaustionRecord, + RetryPendingRecord, RetryReason, RuntimeStreamState, Scheduler, SchedulerConfig, + SchedulerStatus, TimestampMs, TrackerBackend, TrackerIssue, TrackerIssueBlocker, + TrackerIssueState, TrackerIssueStateKind, TrackerIssueStateSnapshot, TrackerIssueSummary, + WorkerAbortReason, WorkerBackend, WorkerId, WorkerInterruptAcknowledgement, WorkerLaunch, + WorkerOutcomeKind, WorkerOutcomeRecord, WorkerStartRequest, WorkerUpdate, WorkspaceBackend, + WorkspaceKey, WorkspaceRecord, decide_issue_route, }; use crate::opensymphony_workflow::RoutingConfig; use chrono::{TimeZone, Utc}; @@ -78,6 +79,7 @@ fn tracker_issue(id: &str, identifier: &str, state: &str, created_at: u64) -> Tr state_kind: tracker_issue_state_kind_from_name(state), branch_name: None, pr_url: None, + pr_urls: Vec::new(), labels: Vec::new(), project_id: None, project_slug: None, @@ -139,6 +141,7 @@ fn normalized_issue(id: &str, identifier: &str, state: &str) -> NormalizedIssue }, branch_name: None, pr_url: None, + pr_urls: Vec::new(), url: Some(format!("https://linear.app/example/{identifier}")), labels: Vec::new(), project_id: None, @@ -329,6 +332,14 @@ impl FakeError { retry_after: Some(retry_after), } } + + fn not_found() -> Self { + Self { + message: "issue not found".to_string(), + category: Some(TrackerErrorCategory::NotFound), + retry_after: None, + } + } } impl std::fmt::Display for FakeError { @@ -355,6 +366,9 @@ struct FakeTracker { terminal_requests: usize, detail_requests: Vec>, state_requests: Vec>, + parent_evidence: Option, + parent_evidence_errors: VecDeque, + parent_evidence_requests: usize, } impl TrackerBackend for FakeTracker { @@ -423,6 +437,24 @@ impl TrackerBackend for FakeTracker { .collect()) } + async fn parent_eligibility( + &mut self, + _parent: &TrackerIssue, + _hierarchy: &HierarchySnapshot, + ) -> Result { + self.parent_evidence_requests += 1; + if let Some(error) = self.parent_evidence_errors.pop_front() { + return Err(error); + } + Ok(self + .parent_evidence + .clone() + .unwrap_or_else(|| ParentEligibilityEvidence { + hierarchy_generation: 0, + children: Vec::new(), + })) + } + fn error_category(error: &Self::Error) -> Option { error.category } @@ -457,6 +489,10 @@ struct FakeWorkspace { persisted_interrupt_reasons: Vec, persist_interrupt_results: VecDeque>, retain_failed: bool, + durable_state: Option, + persisted_durable_states: Vec, + persist_durable_state_results: VecDeque>, + recovered_run_started_at: BTreeMap, } impl WorkspaceBackend for FakeWorkspace { @@ -488,6 +524,28 @@ impl WorkspaceBackend for FakeWorkspace { Ok(self.recoveries.clone()) } + async fn recovered_run_started_at( + &mut self, + ) -> Result, Self::Error> { + Ok(self.recovered_run_started_at.clone()) + } + + async fn load_orchestrator_state(&mut self) -> Result, Self::Error> { + Ok(self.durable_state.clone()) + } + + async fn persist_orchestrator_state( + &mut self, + state: &serde_json::Value, + ) -> Result<(), Self::Error> { + if let Some(result) = self.persist_durable_state_results.pop_front() { + result?; + } + self.durable_state = Some(state.clone()); + self.persisted_durable_states.push(state.clone()); + Ok(()) + } + async fn recover_retry_exhaustion( &mut self, ) -> Result, Self::Error> { @@ -621,6 +679,7 @@ impl WorkerBackend for FakeWorker { Some(result) => result, None => Ok(WorkerLaunch { conversation: conversation(&request.run.worker_id), + started_at: None, }), } } @@ -653,6 +712,861 @@ impl WorkerBackend for FakeWorker { } } +#[tokio::test] +async fn parent_provider_cooldown_stops_later_lookups_in_the_same_dispatch_pass() { + let mut parent_a = tracker_issue("parent-a", "COE-A", "In Progress", 1); + parent_a.sub_issues = vec![TrackerIssueRef { + id: "child".to_owned(), + identifier: "COE-CHILD".to_owned(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let mut parent_b = parent_a.clone(); + parent_b.id = "parent-b".to_owned(); + parent_b.identifier = "COE-B".to_owned(); + let tracker = FakeTracker { + active: vec![ + tracker_issue("leaf", "COE-LEAF", "In Progress", 0), + parent_a, + parent_b, + ], + parent_evidence_errors: VecDeque::from([FakeError { + message: "provider rate limit".to_owned(), + category: Some(TrackerErrorCategory::RateLimited), + retry_after: Some(Duration::from_secs(60)), + }]), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace::default(), + FakeWorker::default(), + scheduler_config(), + ); + scheduler + .tick(ts(100)) + .await + .expect("rate limit should defer parent dispatch"); + assert_eq!(scheduler.tracker().parent_evidence_requests, 1); + assert_eq!( + scheduler.worker().launches.len(), + 1, + "prepared leaf launch must survive" + ); + scheduler + .tick(ts(200)) + .await + .expect("cooldown tick should still run"); + assert_eq!(scheduler.tracker().parent_evidence_requests, 1); +} + +#[tokio::test] +async fn eligible_parent_dispatches_once_from_durable_claim_after_restart() { + let child_id = IssueId::new("child-1").expect("child id should be valid"); + let child_identifier = IssueIdentifier::new("COE-1-child").expect("child identifier"); + let parent_id = IssueId::new("parent-1").expect("parent id should be valid"); + let mut parent = tracker_issue("parent-1", "COE-1", "In Progress", 0); + parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: child_identifier.to_string(), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let snapshot = HierarchySnapshot::new(&parent); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository id"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot.clone())]), + leases: vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: snapshot.generation, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + ..Default::default() + }; + let evidence = ParentEligibilityEvidence { + hierarchy_generation: snapshot.generation, + children: vec![ChildEligibilityEvidence { + child_id: child_id.clone(), + hierarchy_generation: snapshot.generation, + orchestrator_terminal: true, + provider_merge_confirmed: true, + merge_required: true, + merge_result_commit: Some("merge-commit".to_owned()), + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: vec![RequiredMergeCommit { + repository_id: Some(resource.repository_id.clone()), + commit: "merge-commit".to_owned(), + }], + merge_repository_id: Some(resource.repository_id.clone()), + merge_repository_ids: vec![resource.repository_id.clone()], + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: Some(resource.clone()), + resources: Vec::new(), + unresolved_failure: None, + }], + }; + // Provider terminal flags cannot substitute for scheduler-owned outcomes, + // including when a canceled child no longer requires merge evidence. + for merge_required in [true, false] { + let mut forged_evidence = evidence.clone(); + forged_evidence.children[0].merge_required = merge_required; + if !merge_required { + let child = &mut forged_evidence.children[0]; + child.merge_result_commit = None; + child.merge_result_commits_by_repository.clear(); + child.merge_repository_id = None; + child.merge_repository_ids.clear(); + child.resource = None; + } + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent.clone()], + parent_evidence: Some(forged_evidence), + ..Default::default() + }, + FakeWorkspace { + durable_state: Some( + serde_json::to_value(&durable_state).expect("state should encode"), + ), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + scheduler.tick(ts(100)).await.expect("parent should defer"); + assert!( + scheduler.worker().launches.is_empty(), + "provider supplied terminal authority" + ); + } + durable_state + .terminal_orchestrator_issues + .insert(child_id.clone()); + // An old success receipt must not admit the parent while a reopened child + // is unclaimed or already prepared in the same launch batch. + for child_first in [true, false] { + let mut reopened_child = tracker_issue("child-1", "COE-1-child", "In Progress", 0); + reopened_child.parent_id = Some(parent_id.to_string()); + reopened_child.priority = Some(if child_first { 0 } else { 2 }); + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent.clone(), reopened_child], + parent_evidence: Some(evidence.clone()), + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state")), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + scheduler + .tick(ts(100)) + .await + .expect("reopened child dispatch"); + assert_eq!( + scheduler.worker().launches.len(), + 1, + "old receipt admitted parent beside reopened child" + ); + assert_eq!(scheduler.worker().launches[0].issue.id, child_id); + let persisted: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert!(!persisted.terminal_orchestrator_issues.contains(&child_id)); + } + let mut evidence = evidence; + evidence.children[0].orchestrator_terminal = false; + let tracker = FakeTracker { + active: vec![parent.clone()], + parent_evidence: Some(evidence.clone()), + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut scheduler = Scheduler::new(tracker, workspace, worker, scheduler_config()); + + scheduler + .tick(ts(100)) + .await + .expect("eligible parent should dispatch"); + assert_eq!(scheduler.worker().launches.len(), 1); + assert!(scheduler.workspace().persisted_durable_states.iter().any(|state| { + serde_json::from_value::( + state.clone(), + ) + .ok() + .and_then(|state| state.hierarchy.get(&parent_id).cloned()) + .is_some_and(|snapshot| { + snapshot.dispatch_intended() + && snapshot.dispatch_required_merge_commits + == vec![RequiredMergeCommit { + repository_id: Some(resource.repository_id.clone()), + commit: "merge-commit".to_owned(), + }] + }) + })); + durable_state = serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("dispatch should persist durable state"), + ) + .expect("durable state should decode"); + assert!(durable_state.hierarchy[&parent_id].dispatch_claimed()); + assert!( + durable_state + .leases + .iter() + .any(|lease| { lease.kind == LeaseKind::AncestorIntegration && lease.active() }) + ); + assert!( + durable_state + .leases + .iter() + .any(|lease| { lease.kind == LeaseKind::LeafWorker && lease.released_at.is_some() }) + ); + + let tracker = FakeTracker { + active: vec![parent], + parent_evidence: Some(evidence), + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), + ..Default::default() + }; + let mut restarted = Scheduler::new( + tracker, + workspace, + FakeWorker::default(), + scheduler_config(), + ); + restarted + .tick(ts(200)) + .await + .expect("restart should respect the durable dispatch claim"); + assert!(restarted.worker().launches.is_empty()); +} + +#[tokio::test] +async fn hierarchy_reconciliation_preserves_reparented_leases_and_releases_terminal_roots() { + let child_id = IssueId::new("child").expect("child"); + let mut old_parent = tracker_issue("old-parent", "COE-OLD", "In Progress", 0); + old_parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: "COE-CHILD".to_owned(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + for reparented in [true, false] { + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([( + IssueId::new("old-parent").expect("parent"), + HierarchySnapshot::new(&old_parent), + )]), + leases: vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }, + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + ..Default::default() + }; + let mut tracker = FakeTracker::default(); + if reparented { + let mut current_parent = old_parent.clone(); + current_parent.sub_issues.clear(); + let mut child = tracker_issue("child", "COE-CHILD", "Done", 0); + child.parent_id = Some("new-parent".to_owned()); + tracker.active = vec![current_parent]; + tracker.terminal = vec![child]; + } else { + let mut terminal_parent = old_parent.clone(); + terminal_parent.state = "Canceled".to_owned(); + terminal_parent.state_kind = TrackerIssueStateKind::Canceled; + tracker.terminal = vec![terminal_parent]; + // A new active hierarchy must not short-circuit terminal reconciliation. + let mut active_parent = tracker_issue("active-parent", "COE-ACTIVE", "In Progress", 0); + active_parent.sub_issues = vec![TrackerIssueRef { + id: "unrelated".to_owned(), + identifier: "COE-UNRELATED".to_owned(), + title: None, + url: None, + state: "In Progress".to_owned(), + state_kind: TrackerIssueStateKind::Started, + }]; + tracker.active = vec![active_parent]; + } + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state")), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + scheduler.tick(ts(100)).await.expect("reconcile hierarchy"); + let persisted: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("persisted state"), + ) + .expect("state"); + assert_eq!( + persisted.leases.iter().any(LeaseRecord::active), + reparented, + "reparented={reparented}" + ); + } +} + +#[tokio::test] +async fn partial_hierarchy_refresh_defers_removal_until_full_reachability_is_known() { + let child_id = IssueId::new("moved-child").expect("child"); + let parent_id = IssueId::new("old-parent").expect("parent"); + let mut parent = tracker_issue("old-parent", "COE-OLD", "In Progress", 0); + parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: "COE-CHILD".to_owned(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), HierarchySnapshot::new(&parent))]), + leases: vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }, + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + ..Default::default() + }; + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent.clone()], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state")), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + scheduler + .tick(ts(100)) + .await + .expect("initial full observation"); + parent.sub_issues.clear(); + scheduler.tracker_mut().active = vec![parent]; + let mut child = tracker_issue("moved-child", "COE-CHILD", "Done", 0); + child.parent_id = Some("new-parent".to_owned()); + scheduler.tracker_mut().terminal = vec![child]; + scheduler.tick(ts(60_100)).await.expect("summary discovery"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode"); + assert!( + state.leases.iter().any(LeaseRecord::active), + "partial observation released a reparented lease" + ); + assert_eq!( + state.hierarchy[&parent_id].generation, 1, + "partial scope must remain available for full reconciliation" + ); + scheduler + .tick(ts(60_200)) + .await + .expect("prompt full refresh"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode"); + assert!(state.leases.iter().any(LeaseRecord::active)); + assert!( + state + .hierarchy + .get(&parent_id) + .is_none_or(|snapshot| snapshot.required_child_edges.is_empty()) + ); + assert_eq!(scheduler.tracker().active_requests, 2); +} + +#[tokio::test] +async fn recovered_run_boundary_survives_initial_state_persistence() { + let issue_id = IssueId::new("recovered-boundary").expect("issue id should be valid"); + let run_started_at = ts(50); + let recovered_workspace = workspace_record("COE-BOUNDARY", "/tmp/recovered/COE-BOUNDARY"); + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + issue: normalized_issue("recovered-boundary", "COE-BOUNDARY", "In Progress"), + workspace: recovered_workspace, + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: None, + }], + recovered_run_started_at: BTreeMap::from([(issue_id.clone(), run_started_at)]), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![tracker_issue( + "recovered-boundary", + "COE-BOUNDARY", + "In Progress", + 0, + )], + ..Default::default() + }, + workspace, + FakeWorker::default(), + scheduler_config(), + ); + + scheduler + .bootstrap(ts(100)) + .await + .expect("recovery bootstrap should persist the recovered boundary"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("bootstrap should persist durable state"), + ) + .expect("durable state should decode"); + assert_eq!( + state.run_started_at_by_issue.get(&issue_id), + Some(&run_started_at) + ); +} + +#[tokio::test] +async fn terminal_recovery_prunes_run_boundary_after_cleanup() { + let issue_id = IssueId::new("terminal-recovery").expect("issue id should be valid"); + let recovered_workspace = workspace_record("COE-TERMINAL", "/tmp/recovered/COE-TERMINAL"); + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + issue: normalized_issue("terminal-recovery", "COE-TERMINAL", "Done"), + workspace: recovered_workspace, + successful_run: true, + cancelled_run: false, + completed_run: true, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: None, + }], + recovered_run_started_at: BTreeMap::from([(issue_id.clone(), ts(50))]), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + FakeTracker { + terminal: vec![tracker_issue( + "terminal-recovery", + "COE-TERMINAL", + "Done", + 0, + )], + ..Default::default() + }, + workspace, + FakeWorker::default(), + scheduler_config(), + ); + + scheduler + .bootstrap(ts(100)) + .await + .expect("terminal recovery should complete cleanup"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("cleanup should persist pruned durable state"), + ) + .expect("durable state should decode"); + assert!(!state.run_started_at_by_issue.contains_key(&issue_id)); +} + +#[tokio::test] +async fn active_ancestor_fence_is_checked_before_nested_parent_eligibility() { + let root_id = IssueId::new("root-fence").expect("root id"); + let nested_id = IssueId::new("nested-fence").expect("nested id"); + let canceled_child_id = IssueId::new("canceled-fence").expect("child id"); + let mut root = tracker_issue("root-fence", "COE-FENCE-ROOT", "In Progress", 0); + root.sub_issues = vec![TrackerIssueRef { + id: nested_id.to_string(), + identifier: "COE-FENCE-NESTED".to_owned(), + title: Some("Nested parent".to_owned()), + url: None, + state: "In Progress".to_owned(), + state_kind: TrackerIssueStateKind::Started, + }]; + let mut nested = tracker_issue("nested-fence", "COE-FENCE-NESTED", "In Progress", 1); + nested.parent_id = Some(root_id.to_string()); + nested.sub_issues = vec![TrackerIssueRef { + id: canceled_child_id.to_string(), + identifier: "COE-FENCE-CANCELED".to_owned(), + title: Some("Canceled child".to_owned()), + url: None, + state: "Canceled".to_owned(), + state_kind: TrackerIssueStateKind::Canceled, + }]; + + let mut root_snapshot = HierarchySnapshot::new(&root); + root_snapshot.mark_dispatched(); + let nested_snapshot = HierarchySnapshot::new(&nested); + let resource = LeaseResource { + issue_id: nested_id.clone(), + repository_id: CanonicalRepositoryId::new("github:fence-repo").expect("repository id"), + checkout_generation: "checkout-fence".to_owned(), + }; + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([ + (root_id.clone(), root_snapshot), + (nested_id.clone(), nested_snapshot.clone()), + ]), + leases: vec![LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: LeaseOwner::ancestor(&root_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + ..Default::default() + }; + let tracker = FakeTracker { + active: vec![root, nested], + parent_evidence: Some(ParentEligibilityEvidence { + hierarchy_generation: nested_snapshot.generation, + children: Vec::new(), + }), + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + tracker, + workspace, + FakeWorker::default(), + scheduler_config(), + ); + + scheduler + .tick(ts(100)) + .await + .expect("ancestor fence should defer nested parent"); + + assert!(scheduler.worker().launches.is_empty()); + let persisted = + serde_json::from_value::( + scheduler + .workspace() + .durable_state + .clone() + .expect("state should remain durable"), + ) + .expect("state should decode"); + assert!(!persisted.hierarchy[&nested_id].dispatch_intended()); + assert!( + !persisted + .leases + .iter() + .any(|lease| { lease.active() && lease.owner == LeaseOwner::ancestor(&nested_id) }) + ); +} + +#[tokio::test] +async fn replan_parent_clears_durable_hierarchy_changed_block_without_releasing_leases() { + let parent_id = IssueId::new("parent-replan").expect("parent id"); + let mut parent = tracker_issue("parent-replan", "COE-REPLAN", "In Progress", 0); + parent.sub_issues = vec![TrackerIssueRef { + id: "child-a".to_owned(), + identifier: "COE-CHILD".to_owned(), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let mut snapshot = HierarchySnapshot::new(&parent); + snapshot.freeze().expect("snapshot should freeze"); + snapshot.reconcile(&[TrackerIssueRef { + id: "child-b".to_owned(), + identifier: "COE-CHILD-2".to_owned(), + title: Some("Replacement child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]); + let retained_lease = LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: LeaseResource { + issue_id: IssueId::new("child-a").expect("child id"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository id"), + checkout_generation: "checkout-1".to_owned(), + }, + owner: LeaseOwner::ancestor(&parent_id), + hierarchy_generation: 2, + acquired_at: 1, + expires_at: None, + released_at: None, + }; + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot)]), + leases: vec![retained_lease.clone()], + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + FakeTracker::default(), + workspace, + FakeWorker::default(), + scheduler_config(), + ); + + assert!( + scheduler + .replan_parent(&parent_id, ts(100)) + .await + .expect("replan should persist") + ); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("replanned state should persist"), + ) + .expect("state should decode"); + assert_eq!(state.hierarchy[&parent_id].generation, 2); + assert!(!state.hierarchy[&parent_id].frozen); + assert!(state.hierarchy[&parent_id].blocked_reason.is_none()); + assert_eq!(state.leases, vec![retained_lease]); +} + +#[tokio::test] +async fn replan_parent_target_preserves_tracker_failures_for_retry() { + let tracker = FakeTracker { + detail_errors: VecDeque::from([FakeError::rate_limited(Duration::from_secs(5))]), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace::default(), + FakeWorker::default(), + scheduler_config(), + ); + + let result = scheduler + .replan_parent_target("COE-REPLAN", 1, ts(100)) + .await; + assert!(matches!( + result, + Err(crate::opensymphony_orchestrator::SchedulerError::Tracker { detail }) + if detail == "rate limited" + )); +} + +#[tokio::test] +async fn replan_parent_target_restores_hierarchy_when_persistence_fails() { + let parent_id = IssueId::new("parent-replan").expect("parent id"); + let child_a = TrackerIssueRef { + id: "child-a".to_owned(), + identifier: "COE-CHILD".to_owned(), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }; + let child_b = TrackerIssueRef { + id: "child-b".to_owned(), + identifier: "COE-CHILD-2".to_owned(), + title: Some("Replacement child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }; + let mut durable_parent = tracker_issue("parent-replan", "COE-REPLAN", "In Progress", 0); + durable_parent.sub_issues = vec![child_a.clone()]; + let mut snapshot = HierarchySnapshot::new(&durable_parent); + snapshot.freeze().expect("snapshot should freeze"); + snapshot.reconcile(std::slice::from_ref(&child_b)); + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id, snapshot)]), + leases: vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: LeaseResource { + issue_id: IssueId::new("child-b").expect("child b"), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repo"), + checkout_generation: "checkout-1".to_owned(), + }, + owner: LeaseOwner::leaf_worker(&IssueId::new("child-b").expect("child b")), + hierarchy_generation: 2, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + ..Default::default() + }; + let current_parent = durable_parent; + let mut moved_child = tracker_issue("child-b", "COE-CHILD-2", "Done", 0); + moved_child.parent_id = Some("new-parent".to_owned()); + let tracker = FakeTracker { + terminal: vec![moved_child], + detail_issues: Some(vec![current_parent]), + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), + persist_durable_state_results: VecDeque::from([Err(FakeError { + message: "hierarchy state write failed".to_owned(), + category: None, + retry_after: None, + })]), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + tracker, + workspace, + FakeWorker::default(), + scheduler_config(), + ); + + scheduler + .tracker_mut() + .candidate_errors + .push_back(FakeError::rate_limited(Duration::from_secs(1))); + assert!( + matches!( + scheduler + .replan_parent_target("COE-REPLAN", 2, ts(99)) + .await, + Err(crate::opensymphony_orchestrator::SchedulerError::Tracker { .. }) + ), + "transient refresh failures must retain the action for retry" + ); + assert!(scheduler.workspace().persisted_durable_states.is_empty()); + + let result = scheduler + .replan_parent_target("COE-REPLAN", 2, ts(100)) + .await; + assert!(matches!( + result, + Err(crate::opensymphony_orchestrator::SchedulerError::Workspace { detail }) + if detail == "hierarchy state write failed" + )); + assert_eq!(scheduler.workspace().persisted_durable_states.len(), 0); + + assert!( + !scheduler + .replan_parent_target("COE-REPLAN", 3, ts(101)) + .await + .expect("retry should reject the unpersisted generation") + ); + assert_eq!(scheduler.workspace().persisted_durable_states.len(), 0); + assert!( + !scheduler + .replan_parent_target("COE-REPLAN", 2, ts(102)) + .await + .expect("changed scope rejects stale replan") + ); + let persisted: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("state"); + assert!( + persisted.leases.iter().any(LeaseRecord::active), + "rejected replan released a reparented lease" + ); +} + +#[tokio::test] +async fn replan_parent_target_rejects_a_disappeared_issue_without_aborting_dispatch() { + let tracker = FakeTracker { + detail_errors: VecDeque::from([FakeError::not_found()]), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace::default(), + FakeWorker::default(), + scheduler_config(), + ); + + assert!( + !scheduler + .replan_parent_target("COE-MISSING", 1, ts(100)) + .await + .expect("missing replan targets are recorded as rejected outcomes") + ); +} + #[tokio::test] async fn launch_failure_persists_retry_metadata_before_returning_error() { let tracker = FakeTracker { @@ -5298,12 +6212,13 @@ async fn repository_binding_outcome_blocks_before_workspace_creation() { async fn unlabeled_parent_remains_repository_neutral() { let mut issue = tracker_issue("lin-repo-parent", "COE-548-PARENT", "In Progress", 0); issue.project_id = Some("project-id".to_string()); - issue.sub_issues = vec![crate::opensymphony_domain::TrackerIssueRef { + issue.sub_issues = vec![TrackerIssueRef { id: "lin-repo-parent-child".to_string(), identifier: "COE-548-CHILD".to_string(), title: Some("Child".to_string()), url: Some("https://linear.app/trilogy-ai-coe/issue/COE-548-CHILD".to_string()), state: "Done".to_string(), + state_kind: TrackerIssueStateKind::Completed, }]; let tracker = FakeTracker { active: vec![issue], diff --git a/crates/opensymphony-tui/src/lib.rs b/crates/opensymphony-tui/src/lib.rs index 86465bb98..6b31e4212 100644 --- a/crates/opensymphony-tui/src/lib.rs +++ b/crates/opensymphony-tui/src/lib.rs @@ -4344,6 +4344,8 @@ mod tests { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), diff --git a/crates/opensymphony-tui/tests/reducer.rs b/crates/opensymphony-tui/tests/reducer.rs index 487caf014..798b38464 100644 --- a/crates/opensymphony-tui/tests/reducer.rs +++ b/crates/opensymphony-tui/tests/reducer.rs @@ -81,6 +81,8 @@ fn fixture_with_identifiers(sequence: u64, identifiers: &[String]) -> SnapshotEn max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), @@ -142,6 +144,8 @@ fn reordered_fixture(sequence: u64, identifiers: &[&str]) -> SnapshotEnvelope { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index ea180fe32..a384004b7 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -3033,6 +3033,54 @@ impl WorkspaceManager { .await } + /// Read the scheduler-owned state kept beside managed workspaces. The + /// synthetic handle keeps the same containment and symlink checks as a + /// normal workspace artifact. + pub async fn load_orchestrator_state(&self) -> Result, WorkspaceError> + where + T: DeserializeOwned, + { + if !path_exists(&self.config.root).await? { + return Ok(None); + } + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root.clone()); + self.validate_workspace_handle(&handle).await?; + self.load_manifest(&handle, &self.orchestrator_state_path(&canonical_root)) + .await + } + + pub async fn write_orchestrator_state_atomically( + &self, + state: &T, + ) -> Result<(), WorkspaceError> + where + T: Serialize, + { + self.create_directory(&self.config.root).await?; + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root.clone()); + self.write_json_artifact_atomically( + &handle, + &self.orchestrator_state_path(&canonical_root), + state, + ) + .await + } + + fn orchestrator_state_handle(&self, canonical_root: PathBuf) -> WorkspaceHandle { + WorkspaceHandle::new( + "__opensymphony_orchestrator_state__", + "__opensymphony_orchestrator_state__", + "__opensymphony_orchestrator_state__", + canonical_root, + ) + } + + fn orchestrator_state_path(&self, canonical_root: &Path) -> PathBuf { + canonical_root.join(".opensymphony-orchestrator-state.json") + } + pub async fn load_conversation_manifest( &self, workspace: &WorkspaceHandle, @@ -4982,6 +5030,8 @@ fn build_shell_command(command: &str) -> Command { mod tests { #[cfg(unix)] use std::ffi::OsString; + #[cfg(unix)] + use std::os::unix::fs::symlink; use std::path::PathBuf; use super::{ @@ -5034,6 +5084,77 @@ mod tests { assert_eq!(total_bytes, 3); } + #[tokio::test] + async fn orchestrator_state_round_trips_through_atomic_workspace_artifact() { + let root = tempfile::tempdir().expect("workspace root should exist"); + let manager = WorkspaceManager::new(WorkspaceManagerConfig { + root: root.path().join("workspaces"), + hooks: HookConfig::default(), + cleanup: CleanupConfig::default(), + }) + .expect("workspace manager should be constructed"); + let state = serde_json::json!({ + "schema_version": 1, + "hierarchy": {}, + "leases": [] + }); + + manager + .write_orchestrator_state_atomically(&state) + .await + .expect("scheduler state should write atomically"); + let loaded: Option = manager + .load_orchestrator_state() + .await + .expect("scheduler state should load"); + + assert_eq!(loaded, Some(state)); + assert!( + root.path() + .join("workspaces/.opensymphony-orchestrator-state.json") + .is_file() + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn orchestrator_state_uses_canonical_root_for_symlinked_workspace_root() { + let root = tempfile::tempdir().expect("workspace root should exist"); + let canonical_root = root.path().join("canonical-workspaces"); + let configured_root = root.path().join("configured-workspaces"); + tokio::fs::create_dir_all(&canonical_root) + .await + .expect("canonical workspace root should exist"); + symlink(&canonical_root, &configured_root).expect("workspace root symlink should exist"); + let manager = WorkspaceManager::new(WorkspaceManagerConfig { + root: configured_root, + hooks: HookConfig::default(), + cleanup: CleanupConfig::default(), + }) + .expect("workspace manager should be constructed"); + let state = serde_json::json!({ + "schema_version": 1, + "hierarchy": {}, + "leases": [] + }); + + manager + .write_orchestrator_state_atomically(&state) + .await + .expect("scheduler state should write through a symlinked root"); + let loaded: Option = manager + .load_orchestrator_state() + .await + .expect("scheduler state should load through a symlinked root"); + + assert_eq!(loaded, Some(state)); + assert!( + canonical_root + .join(".opensymphony-orchestrator-state.json") + .is_file() + ); + } + #[tokio::test] async fn abandoned_staging_intent_rejects_mismatched_published_target() { let root = tempfile::tempdir().expect("workspace root should exist"); @@ -5159,11 +5280,15 @@ mod tests { credential_kind: "ssh-agent".to_owned(), credential_reference: None, credential_env: None, + review_credential_env: None, instructions_path: PathBuf::from(".git/hooks/local-instructions.md"), policy_generation: String::new(), review_profile: String::new(), review_provider: String::new(), review_policy_generation: String::new(), + required_checks: false, + required_review: false, + merge_method: None, }; let error = manager diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index de570a01e..20a42ef83 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -23,6 +23,8 @@ pub struct CheckoutRepository { pub credential_reference: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub credential_env: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_credential_env: Option, pub instructions_path: PathBuf, #[serde(default)] pub policy_generation: String, @@ -31,6 +33,12 @@ pub struct CheckoutRepository { pub review_provider: String, #[serde(default)] pub review_policy_generation: String, + #[serde(default)] + pub required_checks: bool, + #[serde(default)] + pub required_review: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_method: Option, } pub const SSH_AUTH_SOCK_ENV: &str = "SSH_AUTH_SOCK"; @@ -51,6 +59,9 @@ pub fn checkout_credential_environment_variables( if let Some(variable) = repository.credential_env.as_ref() { variables.insert(variable.clone()); } + if let Some(variable) = repository.review_credential_env.as_ref() { + variables.insert(variable.clone()); + } if repository.credential_kind == "ssh-agent" { variables.insert(SSH_AUTH_SOCK_ENV.to_owned()); } @@ -919,6 +930,9 @@ pub struct RunManifest { pub interrupt_reason: Option, pub status: RunStatus, pub created_at: DateTime, + /// Durable boundary for fencing provider evidence after a run restarts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option>, pub updated_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] pub status_detail: Option, @@ -947,6 +961,7 @@ impl RunManifest { interrupt_reason: None, status: RunStatus::Preparing, created_at: now, + started_at: None, updated_at: now, status_detail: None, hooks: Vec::new(), diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index b869c63fa..ceb5f7adb 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -322,11 +322,15 @@ async fn verified_checkout_is_atomic_repository_local_and_quarantines_drift() { credential_kind: "environment".to_owned(), credential_reference: None, credential_env: Some("HOME".to_owned()), + review_credential_env: None, instructions_path: "AGENTS.md".into(), policy_generation: "scheduler-policy-1".to_owned(), review_profile: "local".to_owned(), review_provider: "local".to_owned(), review_policy_generation: "review-policy-1".to_owned(), + required_checks: false, + required_review: false, + merge_method: None, }; let missing_policy_manager = WorkspaceManager::new(manager_config( &temp_dir.path().join("workspaces"), diff --git a/docs/architecture.md b/docs/architecture.md index 6df514ac3..c67ce2753 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,14 @@ Rust owns: OpenHands conversation state is informative, not authoritative. +Parent admission derives child terminal status from scheduler-owned durable +outcomes or released executions. Provider-supplied terminal flags cannot +approve dispatch. A current execution supersedes any older success receipt; +unclaimed, claimed, running, and retry-queued children remain ineligible, +including when a subtree no longer requires merge evidence. Reopening or +recovering nonterminal work invalidates its durable success receipt before +batched launch preparation. + Repository routing is also orchestrator-owned: terminal child metadata carries one alias, the central inventory resolves it to a canonical provider identity, and the scheduler persists that identity plus its config and inventory @@ -78,6 +86,14 @@ Repository binding outcomes are carried into the control-plane issue snapshot; invalid outcomes mark the issue blocked while preserving the typed diagnostic for operator clients. +Hierarchy reconciliation is scheduler-owned as a separate generation axis from +checkout, run, and attempt generations. The scheduler persists required child +edges and owner-identified leases through the workspace manager's atomic JSON +artifact path. Parent dispatch consumes provider-backed merge evidence only +after terminal orchestrator outcomes and retained checkout-generation lease +resources are present; scope changes after freeze are recorded as +`HierarchyChanged` and cannot silently widen the parent run. + ### 3.2 OpenHands is the execution adapter OpenHands provides: diff --git a/docs/configuration.md b/docs/configuration.md index b2f8b0a66..e8035035d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,7 +61,9 @@ prompts, logs, or process arguments. The resulting config and inventory generations are copied into the run envelope and must match on recovery. The checkout credential must match the clone transport: `ssh-agent` is valid for SSH clone URLs, while `environment` credentials are rejected for SSH -clones. +clones. Active GitHub review profiles must separately resolve to an +`environment` credential with a variable name, because review API requests do +not use the checkout transport credential or ambient authentication. Queued retries do not advance the durable retry count until dispatch begins, so a restart during the backoff window cannot mistake a pending retry for an exhausted one. Recovery restores persisted non-exhausted retry counts before diff --git a/docs/linear-and-tools.md b/docs/linear-and-tools.md index 0de77d248..e34c84d6a 100644 --- a/docs/linear-and-tools.md +++ b/docs/linear-and-tools.md @@ -120,10 +120,15 @@ Important normalization rules: - `branch_name` comes from Linear `Issue.branchName` when present and is carried through tracker normalization so run-detail clients can show the same branch known to the scheduler -- `pr_url` comes from Linear issue attachments only when the attachment has an - explicit GitHub `sourceType` and a canonical - `https://github.com///pull/` URL; generic URL - attachments are ignored for Run Detail PR metadata +- `pr_urls` retains qualifying URLs from the 32 most recently updated Linear + attachments containing `/pull/`, fetched in the issue query without historical + attachment pagination. When more exist, hydration logs the truncation. The explicit + `sourceType` must be GitHub or API (including links created by the agent-side + GraphQL helper), and the URL must be an HTTPS pull-request URL in the + `//pull/` shape. This accepts both github.com and + GitHub Enterprise authorities; non-PR URL attachments are ignored for Run + Detail PR metadata. `pr_url` remains the first qualifying URL as a + compatibility projection for consumers that only display one pull request. - `parent_id` comes from `parent.id` - `parent` retains the parent identifier when Linear returns it, and gateway task graph nodes use that identifier as the client-facing `parent_id`; the diff --git a/docs/operations.md b/docs/operations.md index 1bad8a3da..91e2f59ac 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -19,6 +19,12 @@ Recommended CLI commands: - `opensymphony doctor` - `opensymphony rehydrate --reason "..."` +During restart recovery, `opensymphony run` loads the scheduler-owned +hierarchy and lease artifact before reconciling terminal workspaces. The +artifact is an internal durable record; operators should not edit it manually. +Recovery rebuilds ancestor retention before cleanup, and cleanup remains +blocked while any owner-identified lease for the checkout generation is active. + ## 2. First-run flow ```bash @@ -450,6 +456,8 @@ Operational implications: issue details hourly after startup/dispatch - if Linear returns a long rate-limit reset, the scheduler pauses all Linear reads behind one shared cooldown but continues processing worker updates; the + same cooldown also suppresses later parent-provider eligibility lookups in + the current dispatch pass without discarding prepared leaf launches. The Linear client only sleeps inline for short rate-limit retry windows up to the lower of `tracker.retry_policy.max_backoff` and 30 seconds - the checked-in helper lives at diff --git a/docs/remote-stream-protocol-decisions.md b/docs/remote-stream-protocol-decisions.md index 9b20a04f5..9490c502c 100644 --- a/docs/remote-stream-protocol-decisions.md +++ b/docs/remote-stream-protocol-decisions.md @@ -38,7 +38,11 @@ state: that advance the frontier (`last + 1`). Events at or below the frontier are suppressed as duplicates. - **Idempotency keys.** Mutations carry an idempotency key so retries after a - disconnect do not double-apply. Action dispatches include `correlation_id`. + disconnect do not double-apply. Run lifecycle helpers (`retryRun`, `cancelRun`, + `resumeRun`, and `rehydrateRun`) generate a fresh operation ID per invocation. + Pass the same optional `operationId` when retrying one request after a lost + response; use a new ID for a later lifecycle attempt. Action dispatches include + `correlation_id`. - **Action receipts.** Every action returns an `ActionReceipt` with `correlation_id` and `expected_followup`. `StreamCorrelator` links streamed follow-up events back to their originating receipt by `correlation_id`. diff --git a/docs/specs/multi-repo-orchestration-spec.md b/docs/specs/multi-repo-orchestration-spec.md index eb2302e29..a2102d5d9 100644 --- a/docs/specs/multi-repo-orchestration-spec.md +++ b/docs/specs/multi-repo-orchestration-spec.md @@ -297,7 +297,7 @@ review_profiles: credential: github-app required_checks: true required_review: true - merge_method: squash + merge_method: merge credentials: linear-main: diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index f124c5177..bbadcffe6 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -89,7 +89,7 @@ Current implementation: - `cargo test` exercises the full root package, including the fake-server contract suite from `tests/fake_server_contract.rs` - `cargo test --test linear_client` exercises fixture-backed GraphQL normalization, parent/child hierarchy extraction, personal-API-key auth headers, required API-key/project/state configuration validation, issue URL/raw-priority preservation, full label pagination, raw workflow-state type preservation alongside normalized kinds, non-archived candidate polling, lightweight dispatch-summary reads, archived terminal cleanup reads, archived by-ID state refresh, GraphQL 400/429 rate-limit retries including reset-header handling, long rate-limit reset return-without-sleep behavior with the 30s inline cap, retryable 5xx GraphQL error envelopes, project-scoped by-ID state refresh with bounded unscoped fallback for moved issues, and tracker error mapping against a local stub server - `cargo test --test linear_client live_linear_client_reads_opensymphony_project -- --ignored --nocapture` is a read-only live evidence probe for PRs that need to show the actual Linear HTTP/GraphQL client path against the OpenSymphony project -- `cargo test --test hierarchy_selection --test scheduler` exercises blocker-aware and hierarchy-aware dispatch filtering, leaf-before-parent ordering, cached per-state capacity limiting, continuation retry, exponential failure backoff, runtime-event-fed stall detection, terminal cleanup/release, active-state reconciliation, Linear cooldown behavior, separated Linear polling cadences, manifest-backed workspace recovery against fake tracker/workspace/worker backends, external retry-marker proof, metadata-only workspace recovery, binding-drift workspace rematerialization, legacy parent neutrality, same-ID generation retention, independent running/discovery cadences, and adoption of every launched worker after persistence errors +- `cargo test --test hierarchy_selection --test scheduler` exercises blocker-aware and hierarchy-aware dispatch filtering, child-edge generation reconciliation and freeze fencing, owner-identified lease acquisition, provider-evidence parent eligibility, leaf-before-parent ordering, cached per-state capacity limiting, continuation retry, exponential failure backoff, runtime-event-fed stall detection, terminal cleanup/release, active-state reconciliation, Linear cooldown behavior, separated Linear polling cadences, manifest-backed workspace recovery against fake tracker/workspace/worker backends, external retry-marker proof, metadata-only workspace recovery, binding-drift workspace rematerialization, legacy parent neutrality, same-ID generation retention, independent running/discovery cadences, and adoption of every launched worker after persistence errors - `cargo test --lib orchestrator_run::backends::tests` covers runtime workspace-manifest recovery, in-flight run detection from `run.json`, and launch-path failure handling in the concrete CLI adapter - `cargo test --test workspace_manager` covers durable checkout/staging ownership-marker sweeps, preservation of foreign generation-shaped directories and staging content, receipt-owned recovery, and the retry verification mode that permits legitimate worker changes while retaining checkout provenance checks - `cargo test --lib opensymphony_workspace::manager::tests::discover_agents` and the memory scope tests cover bounded tracked-instruction probes, failure propagation, canonical project-ID filtering, and project-scoped direct capsule reads diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 843e9a764..c932e0dc0 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -99,6 +99,29 @@ streams and fail closed above 1 MiB per file or 4 MiB across the selected and natively discovered instruction set; prompt loading applies the same per-file limit before retaining content in memory. +### Hierarchy leases and terminal retention + +The scheduler stores hierarchy snapshots and durable leases in the workspace +root's `.opensymphony-orchestrator-state.json` through +`WorkspaceManager::write_json_artifact_atomically`. A lease resource is the +existing issue ID, canonical repository ID, and checkout generation; hierarchy +state never stores an alternate checkout path. Terminal cleanup, including +failed, forced, and restart-recovery cleanup, checks the active owner-identified +leases before removal. A leased checkout and its conversation evidence remain +present until all applicable review and ancestor leases are released. + +When a nested issue moves to another parent, release checks preserve leases +through its required descendant edges, including when the new parent exists +only in the current tracker observation. Removing or canceling the old root +must not release a still-required descendant's leaf, review, or ancestor leases. +The old parent's review ownership can be released independently; a later +leaf-only cleanup pass must not override the reachability decision. + +A partial issue refresh that changes an existing hierarchy keeps the prior +scope and leases and requests a full tracker observation on the next tick. +Generation-checked replans also fetch full reachability before reconciling +changed edges, so rejecting a stale replan cannot discard reparented evidence. + ## 4. Workspace directory layout Recommended layout inside each issue workspace: diff --git a/packages/api-client/__tests__/run-actions.test.ts b/packages/api-client/__tests__/run-actions.test.ts index b8c45707d..224e1ab17 100644 --- a/packages/api-client/__tests__/run-actions.test.ts +++ b/packages/api-client/__tests__/run-actions.test.ts @@ -63,7 +63,13 @@ describe("MockGatewayTransport action methods", () => { expect(result.status).toBe("accepted"); }); - it("retryRun returns a receipt with expected events and deterministic idempotency key", async () => { + it("replanParent returns a receipt for a hierarchy parent", async () => { + const result = await transport.replanParent("parent-1", 7); + assertReceiptShape(result); + expect(result.correlation_id).toContain("replan-parent-1-"); + }); + + it("retryRun returns a receipt with expected events", async () => { const result = await transport.retryRun(runDetail.run_id); assertReceiptShape(result); expect(result.correlation_id).toContain(`retry-${runDetail.run_id}-`); @@ -134,7 +140,7 @@ describe("HttpGatewayTransport action integration", () => { it("cancelRun POSTs a cancel action to the dispatch endpoint", async () => { const fetchSpy = mockFetch(receipt); const transport = new HttpGatewayTransport({ baseUri }); - const result = await transport.cancelRun("run-1"); + const result = await transport.cancelRun("run-1", "attempt-1"); expect(fetchSpy).toHaveBeenCalledTimes(1); const requestUrl = fetchSpy.mock.calls[0][0] as string; @@ -144,19 +150,67 @@ describe("HttpGatewayTransport action integration", () => { const body = JSON.parse(requestInit.body as string); expect(body.action_kind).toBe("cancel"); expect(body.target_entity).toEqual({ entity_kind: "run", entity_id: "run-1" }); - expect(body.idempotency_key).toBe("cancel-run-1"); + expect(body.idempotency_key).toBe("cancel-run-1-attempt-1"); expect(result.correlation_id).toBe(receipt.correlation_id); }); it("retryRun includes retry action kind and idempotency key", async () => { const fetchSpy = mockFetch(receipt); const transport = new HttpGatewayTransport({ baseUri }); - await transport.retryRun("run-2"); + await transport.retryRun("run-2", "attempt-2"); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; const body = JSON.parse(requestInit.body as string); expect(body.action_kind).toBe("retry"); - expect(body.idempotency_key).toBe("retry-run-2"); + expect(body.idempotency_key).toBe("retry-run-2-attempt-2"); + }); + + it.each(["retryRun", "cancelRun", "resumeRun", "rehydrateRun"] as const)( + "%s distinguishes later attempts and preserves request retry identity", + async (method) => { + const fetchSpy = mockFetch(receipt); + const transport = new HttpGatewayTransport({ baseUri }); + await transport[method]("run-1"); + await transport[method]("run-1"); + await transport[method]("run-1", "operation-1"); + await transport[method]("run-1", "operation-1"); + const actions = fetchSpy.mock.calls.map((call) => + JSON.parse((call[1] as RequestInit).body as string), + ); + expect(actions[0].idempotency_key).not.toBe(actions[1].idempotency_key); + expect(actions[2].idempotency_key).toBe(actions[3].idempotency_key); + expect(actions[2].correlation_id).not.toBe(actions[3].correlation_id); + }, + ); + + it("replanParent POSTs a replan action for an issue", async () => { + const fetchSpy = mockFetch(receipt); + const transport = new HttpGatewayTransport({ baseUri }); + await transport.replanParent("parent-1", 7, "generation-7"); + + const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; + const body = JSON.parse(requestInit.body as string); + expect(body.action_kind).toBe("replan"); + expect(body.target_entity).toEqual({ entity_kind: "issue", entity_id: "parent-1" }); + expect(body.payload).toEqual({ hierarchy_generation: 7 }); + expect(body.idempotency_key).toBe("replan-parent-1-generation-7"); + }); + + it("replanParent gives each new operation a key while allowing request retries", async () => { + const fetchSpy = mockFetch(receipt); + const transport = new HttpGatewayTransport({ baseUri }); + await transport.replanParent("parent-1", 7, "generation-7"); + await transport.replanParent("parent-1", 7, "generation-7"); + await transport.replanParent("parent-1", 8, "generation-8"); + + const keys = fetchSpy.mock.calls.map((call) => + JSON.parse((call[1] as RequestInit).body as string).idempotency_key, + ); + expect(keys).toEqual([ + "replan-parent-1-generation-7", + "replan-parent-1-generation-7", + "replan-parent-1-generation-8", + ]); }); it("approvalDecision POSTs the decision and explanation", async () => { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 063b71c23..e8e9b23bf 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -112,10 +112,23 @@ export interface GatewayTransport { /** Extended transport with action dispatch support. */ export interface ActionCapableTransport extends GatewayTransport { dispatchAction(action: ActionDispatch): Promise; - cancelRun(runId: string): Promise; - retryRun(runId: string): Promise; - resumeRun(runId: string): Promise; - rehydrateRun(runId: string): Promise; + /** Run lifecycle actions use a fresh operation ID by default; reuse it for request retries. */ + cancelRun(runId: string, operationId?: string): Promise; + /** + * Replan one blocked hierarchy generation. The displayed generation is + * required so the orchestrator can reject stale control-plane actions. + * Callers that retry the same request should reuse operationId; a new + * operation gets a new key so a later hierarchy generation is not rejected + * as a duplicate. + */ + replanParent( + parentId: string, + hierarchyGeneration: number, + operationId?: string, + ): Promise; + retryRun(runId: string, operationId?: string): Promise; + resumeRun(runId: string, operationId?: string): Promise; + rehydrateRun(runId: string, operationId?: string): Promise; commentRun(runId: string, text: string): Promise; createFollowup(runId: string, payload: unknown): Promise; approvalDecision(approvalId: string, decision: "approved" | "rejected", explanation?: string): Promise; @@ -191,4 +204,4 @@ export type { RunPhase, SafeActions, RunLifecycleState, -}; \ No newline at end of file +}; diff --git a/packages/api-client/src/mock.ts b/packages/api-client/src/mock.ts index 75f522d23..8aef45ecb 100644 --- a/packages/api-client/src/mock.ts +++ b/packages/api-client/src/mock.ts @@ -463,43 +463,58 @@ export class MockGatewayTransport implements GatewayTransport, ActionCapableTran }); } - async cancelRun(runId: string): Promise { + async cancelRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `cancel-${runId}-${crypto.randomUUID()}`, action_kind: "cancel", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `cancel-${runId}`, + idempotency_key: `cancel-${runId}-${operationId}`, }); } - async retryRun(runId: string): Promise { + async replanParent( + parentId: string, + hierarchyGeneration: number, + operationId = crypto.randomUUID(), + ): Promise { + return this.dispatchAction({ + schema_version: { major: 1, minor: 0, patch: 0 }, + correlation_id: `replan-${parentId}-${crypto.randomUUID()}`, + action_kind: "replan", + target_entity: { entity_kind: "issue", entity_id: parentId }, + payload: { hierarchy_generation: hierarchyGeneration }, + idempotency_key: `replan-${parentId}-${operationId}`, + }); + } + + async retryRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `retry-${runId}-${crypto.randomUUID()}`, action_kind: "retry", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `retry-${runId}`, + idempotency_key: `retry-${runId}-${operationId}`, }); } - async resumeRun(runId: string): Promise { + async resumeRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `resume-${runId}-${crypto.randomUUID()}`, action_kind: "resume", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `resume-${runId}`, + idempotency_key: `resume-${runId}-${operationId}`, }); } - async rehydrateRun(runId: string): Promise { + async rehydrateRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `rehydrate-${runId}-${crypto.randomUUID()}`, action_kind: "rehydrate", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `rehydrate-${runId}`, + idempotency_key: `rehydrate-${runId}-${operationId}`, }); } diff --git a/packages/api-client/src/transports.ts b/packages/api-client/src/transports.ts index 877f62f84..2ef15a284 100644 --- a/packages/api-client/src/transports.ts +++ b/packages/api-client/src/transports.ts @@ -365,43 +365,58 @@ export class HttpGatewayTransport implements GatewayTransport, ActionCapableTran return response as ActionReceipt; } - async cancelRun(runId: string): Promise { + async cancelRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `cancel-${runId}-${crypto.randomUUID()}`, action_kind: "cancel", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `cancel-${runId}`, + idempotency_key: `cancel-${runId}-${operationId}`, }); } - async retryRun(runId: string): Promise { + async replanParent( + parentId: string, + hierarchyGeneration: number, + operationId = crypto.randomUUID(), + ): Promise { + return this.dispatchAction({ + schema_version: { major: 1, minor: 0, patch: 0 }, + correlation_id: `replan-${parentId}-${crypto.randomUUID()}`, + action_kind: "replan", + target_entity: { entity_kind: "issue", entity_id: parentId }, + payload: { hierarchy_generation: hierarchyGeneration }, + idempotency_key: `replan-${parentId}-${operationId}`, + }); + } + + async retryRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `retry-${runId}-${crypto.randomUUID()}`, action_kind: "retry", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `retry-${runId}`, + idempotency_key: `retry-${runId}-${operationId}`, }); } - async resumeRun(runId: string): Promise { + async resumeRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `resume-${runId}-${crypto.randomUUID()}`, action_kind: "resume", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `resume-${runId}`, + idempotency_key: `resume-${runId}-${operationId}`, }); } - async rehydrateRun(runId: string): Promise { + async rehydrateRun(runId: string, operationId = crypto.randomUUID()): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `rehydrate-${runId}-${crypto.randomUUID()}`, action_kind: "rehydrate", target_entity: { entity_kind: "run", entity_id: runId }, - idempotency_key: `rehydrate-${runId}`, + idempotency_key: `rehydrate-${runId}-${operationId}`, }); } diff --git a/packages/gateway-schema/src/action.ts b/packages/gateway-schema/src/action.ts index bf772e557..053a2b9fc 100644 --- a/packages/gateway-schema/src/action.ts +++ b/packages/gateway-schema/src/action.ts @@ -4,6 +4,7 @@ import type { SchemaVersion } from "./version.js"; export type ActionKind = | "retry" | "cancel" + | "replan" | "pause" | "resume" | "rehydrate" diff --git a/tests/action_handler.rs b/tests/action_handler.rs index e40ffe043..c80016cb1 100644 --- a/tests/action_handler.rs +++ b/tests/action_handler.rs @@ -69,6 +69,8 @@ fn fixture_snapshot( max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: None, @@ -302,7 +304,7 @@ async fn action_handler_publishes_rejected_event_to_journal() { } #[tokio::test] -async fn action_handler_rejects_duplicate_idempotency_key() { +async fn action_handler_replays_duplicate_idempotency_key() { let journal = InMemoryEventJournal::new(1000, 64); let handler = ActionHandler::new(journal.clone()); let envelope = fixture_envelope( @@ -315,14 +317,9 @@ async fn action_handler_rejects_duplicate_idempotency_key() { let receipt1 = handler.dispatch(dispatch.clone(), &envelope).await; assert_eq!(receipt1.status, ActionStatus::Accepted); let receipt2 = handler.dispatch(dispatch, &envelope).await; - assert_eq!(receipt2.status, ActionStatus::Rejected); - assert!( - receipt2 - .reason - .as_ref() - .expect("should not be None") - .contains("duplicate idempotency key") - ); + assert_eq!(receipt2.status, ActionStatus::Accepted); + assert_eq!(receipt2.action_id, receipt1.action_id); + assert_eq!(receipt2.correlation_id, receipt1.correlation_id); } #[tokio::test] @@ -424,7 +421,7 @@ async fn action_handler_resume_accepted_on_paused_issue() { } /// Concurrent idempotency test: two tasks with the same idempotency key -/// should result in exactly one accepted receipt and one rejected receipt. +/// should produce one dispatch and replay its accepted receipt. #[tokio::test] async fn action_handler_concurrent_idempotency_only_one_accepted() { let journal = InMemoryEventJournal::new(1000, 64); @@ -446,20 +443,13 @@ async fn action_handler_concurrent_idempotency_only_one_accepted() { let (r1, r2) = tokio::join!(h1.dispatch(d1, &env1), h2.dispatch(d2, &env2),); - let statuses = vec![r1.status, r2.status]; - assert!( - statuses.contains(&ActionStatus::Accepted), - "exactly one accepted receipt expected; got {:?}", - statuses - ); - assert!( - statuses.contains(&ActionStatus::Rejected), - "exactly one rejected receipt expected; got {:?}", - statuses - ); + assert_eq!(r1.status, ActionStatus::Accepted); + assert_eq!(r2.status, ActionStatus::Accepted); + assert_eq!(r1.action_id, r2.action_id); + assert_eq!(r1.correlation_id, r2.correlation_id); - // Verify exactly one audit event was appended to the journal (no - // duplicate-race event from the rejected dispatch). + // Verify exactly one audit event was appended to the journal (the retry + // replays the receipt without dispatching a second action). let cursor = opensymphony::opensymphony_gateway_schema::cursor::StreamCursor::new(0, "events"); let page = journal.query_after(&cursor, 10).await.expect("query"); assert_eq!( diff --git a/tests/task_graph_mutations_routes.rs b/tests/task_graph_mutations_routes.rs index f9be28467..29e24c987 100644 --- a/tests/task_graph_mutations_routes.rs +++ b/tests/task_graph_mutations_routes.rs @@ -97,6 +97,8 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { max_turns: 0, runtime_seconds: 0, blocked: false, + hierarchy_generation: None, + hierarchy_blocked_reason: None, repository_binding: None, blocked_by: Vec::new(), server_base_url: Some("http://127.0.0.1:3000".to_owned()), From 9abff5a428ad59bf6ef36d8879c9c6a09824329c Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 12 Sep 2026 07:17:22 -0500 Subject: [PATCH 02/29] Add parent execution roots and shared child worktrees (#236) * feat(workspace): add parent execution roots Prepare repository-neutral parent workspaces with generation-bound shared-object integration worktrees and immutable checkout maps. Bind the same validated multi-checkout scope into OpenHands and Codex runtime envelopes and provider-neutral prompts. * fix(workspace): harden parent execution-root reuse Summary: - roll back incomplete roots and bound Git subprocess trees - isolate credentials and revalidate every retained child generation - preserve trusted parent integration changes across scheduler retries - support empty child maps and aggregate instruction size limits Rationale: - parent preparation must remain retryable without weakening containment - reused roots must retain child evidence and in-progress integration work Tests: - cargo test-system-duckdb - cargo clippy-system-duckdb - cargo fmt --check - git diff --check * fix(workspace): verify live child state on parent retries Summary: - compare retained child Git HEAD and branch with pinned parent evidence - preserve parent changes through scheduler retry preparation - cover pre-pinned child commits and committed post-pin drift Rationale: - immutable acquisition manifests do not describe completed child work - retry preparation runs before the worker's retry-aware attachment path Tests: - cargo test-system-duckdb - cargo clippy-system-duckdb - cargo fmt --check - git diff --check * fix(runtime): revalidate parent roots before attach * fix(workspace): verify parent remote locators * fix(workspace): isolate parent preparation probes * fix(workspace): seal parent execution roots * fix(workspace): isolate authenticated parent fetches * fix(workspace): harden parent launch boundaries * fix(workspace): isolate parent verification probes --- Cargo.toml | 3 + crates/opensymphony-cli/src/memory.rs | 1 + .../src/orchestrator_run/backends.rs | 807 +++++++- .../src/orchestrator_run/config.rs | 6 + .../src/orchestrator_run/mod.rs | 2 + crates/opensymphony-cli/tests/run.rs | 1 + crates/opensymphony-openhands/src/session.rs | 107 +- crates/opensymphony-workspace/src/lib.rs | 16 +- crates/opensymphony-workspace/src/manager.rs | 1831 ++++++++++++++++- crates/opensymphony-workspace/src/models.rs | 134 ++ crates/opensymphony-workspace/src/paths.rs | 29 +- .../tests/workspace_manager.rs | 1170 ++++++++++- docs/architecture.md | 14 + docs/codex-app-server-harness.md | 7 + docs/configuration.md | 7 + docs/openhands-agent-server.md | 9 + docs/operations.md | 23 + docs/testing-and-operations.md | 25 + docs/workspace-and-lifecycle.md | 89 + 19 files changed, 4159 insertions(+), 122 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 19e6efdab..544f6f59b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,9 @@ uuid.workspace = true [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs"] } +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + [workspace.package] edition = "2024" license = "MIT" diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index d0023c81e..a14d85aa0 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -14117,6 +14117,7 @@ Public memory concept. workspace_path: workspace.clone(), repository_binding: None, runtime_envelope: Some(runtime_envelope), + parent_runtime_envelope: None, attempt: 2, normal_retry_count: 0, pending_retry: false, diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 5058898a0..4524fbcb2 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -2,6 +2,7 @@ use futures_util::{StreamExt, stream}; use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, env, io, @@ -48,9 +49,10 @@ use crate::opensymphony_orchestrator::{ use crate::opensymphony_workflow::{Environment, ProcessEnvironment, ResolvedWorkflow}; use crate::opensymphony_workspace::{ CheckoutRepository, CleanupConfig, HookConfig, HookDefinition, IssueDescriptor, - IssueLifecycleState, RunDescriptor, RunManifest, RunStatus, TerminalRuntimeEnvelope, - WorkspaceError, WorkspaceHandle, WorkspaceManager, WorkspaceManagerConfig, - checkout_credential_environment_variables, compose_terminal_prompt, + IssueLifecycleState, ParentCheckoutRequest, ParentRuntimeDescriptor, ParentRuntimeEnvelope, + RunDescriptor, RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, + WorkspaceHandle, WorkspaceManager, WorkspaceManagerConfig, + checkout_credential_environment_variables, compose_parent_prompt, compose_terminal_prompt, environment_variable_names_equal, }; use async_trait::async_trait; @@ -66,8 +68,9 @@ use tokio::{ use url::Url; use super::{ - RunCommandError, RuntimeMemoryEnv, config::RunRuntimeConfig, datetime_to_timestamp_ms, - now_timestamp, timestamp_to_datetime, + RunCommandError, RuntimeMemoryEnv, + config::{ResolvedIntegrationInstructions, RunRuntimeConfig}, + datetime_to_timestamp_ms, now_timestamp, timestamp_to_datetime, }; const DEFAULT_WORKER_LAUNCH_TIMEOUT: Duration = Duration::from_secs(60); @@ -187,6 +190,7 @@ pub(super) struct RuntimeWorkerBackend { workpad_comment_source: Option>, worker_env: BTreeMap, checkout_credential_envs: BTreeSet, + integration_instructions: Option, codex_bin: String, codex_schema_validators: CodexSchemaValidatorCache, codex_interrupts: CodexInterruptRegistry, @@ -2353,6 +2357,99 @@ impl GitHubRepository { } } +fn parent_checkout_requests( + state: &DurableOrchestratorState, + parent_id: &IssueId, + snapshot: &HierarchySnapshot, +) -> Result, CliWorkspaceError> { + let resources = state.descendant_resources_for(parent_id); + for edge in snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + { + let mut subtree = BTreeSet::from([edge.child_id.clone()]); + let mut pending = vec![edge.child_id.clone()]; + while let Some(issue_id) = pending.pop() { + if let Some(child_snapshot) = state.hierarchy.get(&issue_id) { + for child in child_snapshot + .required_child_edges + .iter() + .filter(|child| child.required) + { + if subtree.insert(child.child_id.clone()) { + pending.push(child.child_id.clone()); + } + } + } + } + if !resources + .iter() + .any(|resource| subtree.contains(&resource.issue_id)) + { + return Err(CliWorkspaceError::RetryState(format!( + "required child {} has no active generation-bound ancestor lease", + edge.child_identifier + ))); + } + } + let repository_ids = resources + .iter() + .map(|resource| resource.repository_id.clone()) + .collect::>(); + if snapshot + .dispatch_required_merge_commits + .iter() + .any(|commit| commit.repository_id.is_none()) + && repository_ids.len() != 1 + { + return Err(CliWorkspaceError::RetryState( + "repository-neutral merge evidence is ambiguous for a multi-repository parent" + .to_owned(), + )); + } + let owner = crate::opensymphony_orchestrator::LeaseOwner::ancestor(parent_id); + resources + .into_iter() + .map(|resource| { + let lease = state + .leases + .iter() + .find(|lease| { + lease.active() + && lease.owner == owner + && lease.hierarchy_generation == snapshot.generation + && lease.resource == resource + }) + .ok_or_else(|| { + CliWorkspaceError::RetryState(format!( + "parent checkout {} has no active ancestor lease", + resource.checkout_generation + )) + })?; + let required_merge_commits = snapshot + .dispatch_required_merge_commits + .iter() + .filter(|commit| { + commit + .repository_id + .as_ref() + .is_some_and(|repository_id| repository_id == &resource.repository_id) + || (commit.repository_id.is_none() && repository_ids.len() == 1) + }) + .map(|commit| commit.commit.clone()) + .collect(); + Ok(ParentCheckoutRequest { + issue_id: resource.issue_id.to_string(), + repository_id: resource.repository_id.to_string(), + checkout_generation: resource.checkout_generation, + lease_owner: lease.owner.id.clone(), + required_merge_commits, + }) + }) + .collect() +} + impl RuntimeWorkspaceBackend { #[cfg(test)] pub(super) fn new(manager: Arc, workflow: &ResolvedWorkflow) -> Self { @@ -2652,6 +2749,52 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { issue: &NormalizedIssue, _observed_at: TimestampMs, ) -> Result { + if !issue.sub_issues.is_empty() { + let raw = self + .manager + .load_orchestrator_state::() + .await? + .ok_or_else(|| { + CliWorkspaceError::RetryState( + "parent preparation requires durable hierarchy state".to_owned(), + ) + })?; + let state: DurableOrchestratorState = serde_json::from_value(raw).map_err(|error| { + CliWorkspaceError::RetryState(format!( + "invalid durable hierarchy state during parent preparation: {error}" + )) + })?; + state.validate().map_err(CliWorkspaceError::RetryState)?; + let snapshot = state.hierarchy.get(&issue.id).ok_or_else(|| { + CliWorkspaceError::RetryState( + "parent preparation requires a pinned hierarchy snapshot".to_owned(), + ) + })?; + if !snapshot.dispatch_intended() { + return Err(CliWorkspaceError::RetryState( + "parent preparation requires a persisted dispatch intent".to_owned(), + )); + } + let requests = parent_checkout_requests(&state, &issue.id, snapshot)?; + let parent = self + .manager + .prepare_parent_execution_root( + &issue_descriptor(issue), + snapshot.generation, + requests, + ) + .await?; + self.terminal_cleanup_paths + .remove(parent.handle.workspace_path()); + return Ok(crate::opensymphony_domain::WorkspaceRecord { + path: parent.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(parent.handle.workspace_key().to_owned())?, + created_now: parent.created, + created_at: Some(datetime_to_timestamp_ms(parent.manifest.created_at)), + updated_at: Some(datetime_to_timestamp_ms(parent.manifest.updated_at)), + last_seen_tracker_refresh_at: issue.updated_at, + }); + } let ensured = self .manager .ensure_with_checkout_timeout(&issue_descriptor(issue), DEFAULT_WORKER_LAUNCH_TIMEOUT) @@ -3634,6 +3777,7 @@ impl RuntimeWorkerBackend { workpad_comment_source, worker_env, checkout_credential_envs: BTreeSet::new(), + integration_instructions: None, codex_bin: env::var("OPENSYMPHONY_CODEX_BIN").unwrap_or_else(|_| "codex".into()), codex_schema_validators: Arc::new(AsyncMutex::new(HashMap::new())), codex_interrupts: Arc::new(Mutex::new(HashMap::new())), @@ -3650,6 +3794,14 @@ impl RuntimeWorkerBackend { self } + pub(super) fn with_integration_instructions( + mut self, + instructions: Option, + ) -> Self { + self.integration_instructions = instructions; + self + } + pub(super) fn with_openhands_conversation_store( mut self, store: Option, @@ -3719,6 +3871,7 @@ impl RuntimeWorkerBackend { let memory_env = self.memory_env.clone(); let workpad_comment_source = self.workpad_comment_source.clone(); let workspace_manager = self.workspace_manager.clone(); + let integration_instructions = self.integration_instructions.clone(); let openhands_conversation_store = self.openhands_conversation_store.clone(); let workflow = self.workflow.clone(); let updates_tx = self.updates_tx.clone(); @@ -3751,17 +3904,74 @@ impl RuntimeWorkerBackend { workspace_issue.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding)); } - let ensured = match workspace_manager - .ensure_with_run_id(&workspace_issue, Some(&run_id)) - .await - { - Ok(ensured) => ensured, - Err(error) => { - report_launch_failure( - &mut launch_tx, - format!("failed to ensure workspace: {error}"), - ); - return; + let is_parent_retry = recovered + || run.normal_retry_count > 0 + || run.attempt.is_some_and(|attempt| attempt.get() > 1); + let (ensured, parent_execution) = if issue.sub_issues.is_empty() { + match workspace_manager + .ensure_with_run_id(&workspace_issue, Some(&run_id)) + .await + { + Ok(ensured) => (ensured, None), + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to ensure workspace: {error}"), + ); + return; + } + } + } else { + let parent = if is_parent_retry { + workspace_manager + .open_parent_execution_root_at_for_retry( + &workspace_issue, + &run.workspace_path, + ) + .await + } else { + workspace_manager + .open_parent_execution_root_at(&workspace_issue, &run.workspace_path) + .await + }; + match parent { + Ok(parent) => { + let issue_manifest = + match workspace_manager.load_issue_manifest(&parent.handle).await { + Ok(Some(manifest)) => manifest, + Ok(None) => { + report_launch_failure( + &mut launch_tx, + "parent execution root is missing its issue manifest" + .to_owned(), + ); + return; + } + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to load parent issue manifest: {error}"), + ); + return; + } + }; + ( + crate::opensymphony_workspace::EnsureWorkspaceResult { + handle: parent.handle.clone(), + issue_manifest, + created: false, + after_create: None, + }, + Some(parent), + ) + } + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to open parent execution root: {error}"), + ); + return; + } } }; let scheduler_workspace_path = match fs::canonicalize(&run.workspace_path).await { @@ -3850,16 +4060,34 @@ impl RuntimeWorkerBackend { let persisted_conversation_binding = recovered_conversation .as_ref() .filter(|_| !switching_harness) - .and_then(|manifest| manifest.runtime_envelope.as_ref()) - .and_then(|envelope| envelope.conversation_binding.clone()) + .and_then(|manifest| { + manifest + .runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.clone()) + .or_else(|| { + manifest + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.clone()) + }) + }) .or_else(|| { if switching_harness { None } else { - prior_run_manifest - .as_ref() - .and_then(|manifest| manifest.runtime_envelope.as_ref()) - .and_then(|envelope| envelope.conversation_binding.clone()) + prior_run_manifest.as_ref().and_then(|manifest| { + manifest + .runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.clone()) + .or_else(|| { + manifest + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.clone()) + }) + }) } }); let attempt = run.attempt.map(|attempt| attempt.get()).unwrap_or(1); @@ -3918,7 +4146,7 @@ impl RuntimeWorkerBackend { }), requested_execution_scope: "single_checkout".to_owned(), effective_containment: "trusted_host_process_cwd".to_owned(), - conversation_binding: persisted_conversation_binding, + conversation_binding: persisted_conversation_binding.clone(), cleanup_intent: "workspace_manager_owned".to_owned(), }) } @@ -3933,62 +4161,113 @@ impl RuntimeWorkerBackend { } else { None }; - let mut memory_grant_requires_fresh_conversation = false; - let worker_memory_env = memory_env.as_ref().map(|memory| { - let mut scoped = memory.clone(); - scoped.project = worker_memory_project(&issue, &memory.project); - scoped.execution_repo = runtime_envelope - .as_ref() - .map(|envelope| envelope.repository_binding.repository.id.to_string()) - .unwrap_or_else(|| memory.execution_repo.clone()); - scoped.run_id = runtime_envelope - .as_ref() - .map(|envelope| envelope.run_id.clone()); - scoped.attempt = runtime_envelope.as_ref().map(|envelope| envelope.attempt); - scoped.target_commit = runtime_envelope - .as_ref() - .map(|envelope| envelope.target_commit.clone()); - scoped.checkout_head = initially_verified_checkout - .as_ref() - .map(|checkout| checkout.head.clone()); - let authorized_repositories = scoped - .authorized_repositories_by_project - .get(&scoped.project) - .cloned() - .or_else(|| { - scoped - .authorized_repositories_by_project - .iter() - .find(|(project, _)| project.eq_ignore_ascii_case(&scoped.project)) - .map(|(_, repositories)| repositories.clone()) - }) - .filter(|repositories| !repositories.is_empty()) - .unwrap_or_else(|| BTreeSet::from([scoped.execution_repo.clone()])); - scoped.authorized_repositories = authorized_repositories.clone(); - if let Some(grants) = &scoped.scope_grants { - let (token, requires_fresh_conversation) = - grants.issue_or_refresh_with_claims(MemoryScopeGrant { - project: scoped.project.clone(), - project_set: scoped.project_set.clone(), - execution_repo: scoped.execution_repo.clone(), - authorized_repositories, - issue: issue.identifier.to_string(), - run_id: scoped.run_id.clone(), - attempt: scoped.attempt, - checkout_generation: runtime_envelope - .as_ref() - .map(|envelope| envelope.checkout_generation.clone()), - target_commit: scoped.target_commit.clone(), - checkout_head: scoped.checkout_head.clone(), - visibility: scoped.visibility, - capabilities: BTreeSet::new(), - }); - memory_grant_requires_fresh_conversation = requires_fresh_conversation; - scoped.token = Some(token.clone()); + let parent_runtime_envelope = if let Some(parent) = parent_execution.as_ref() { + let mut envelope = workspace_manager.parent_runtime_envelope( + parent, + ParentRuntimeDescriptor { + run_id: run_id.clone(), + attempt, + integration_instruction: integration_instructions.as_ref().map( + |instructions| { + (instructions.path.clone(), instructions.content_hash.clone()) + }, + ), + harness: route.harness_kind.clone(), + model_profile: route + .model_profile + .clone() + .unwrap_or_else(|| "default".to_owned()), + model: route.model.clone().or_else(|| { + if route.harness_kind == OPENHANDS_AGENT_SERVER_KIND { + workflow + .extensions + .openhands + .conversation + .agent + .llm + .as_ref() + .and_then(|llm| llm.model.clone()) + } else { + None + } + }), + effective_containment: "trusted_host".to_owned(), + }, + ); + envelope.conversation_binding = persisted_conversation_binding.clone(); + if let Err(error) = workspace_manager + .write_parent_runtime_envelope(parent, &envelope) + .await + { + report_launch_failure( + &mut launch_tx, + format!("failed to persist parent runtime envelope: {error}"), + ); + return; } - scoped.authorized_repositories_by_project.clear(); - scoped - }); + Some(envelope) + } else { + None + }; + let mut memory_grant_requires_fresh_conversation = false; + let worker_memory_env = memory_env + .as_ref() + .filter(|_| parent_execution.is_none()) + .map(|memory| { + let mut scoped = memory.clone(); + scoped.project = worker_memory_project(&issue, &memory.project); + scoped.execution_repo = runtime_envelope + .as_ref() + .map(|envelope| envelope.repository_binding.repository.id.to_string()) + .unwrap_or_else(|| memory.execution_repo.clone()); + scoped.run_id = runtime_envelope + .as_ref() + .map(|envelope| envelope.run_id.clone()); + scoped.attempt = runtime_envelope.as_ref().map(|envelope| envelope.attempt); + scoped.target_commit = runtime_envelope + .as_ref() + .map(|envelope| envelope.target_commit.clone()); + scoped.checkout_head = initially_verified_checkout + .as_ref() + .map(|checkout| checkout.head.clone()); + let authorized_repositories = scoped + .authorized_repositories_by_project + .get(&scoped.project) + .cloned() + .or_else(|| { + scoped + .authorized_repositories_by_project + .iter() + .find(|(project, _)| project.eq_ignore_ascii_case(&scoped.project)) + .map(|(_, repositories)| repositories.clone()) + }) + .filter(|repositories| !repositories.is_empty()) + .unwrap_or_else(|| BTreeSet::from([scoped.execution_repo.clone()])); + scoped.authorized_repositories = authorized_repositories.clone(); + if let Some(grants) = &scoped.scope_grants { + let (token, requires_fresh_conversation) = grants + .issue_or_refresh_with_claims(MemoryScopeGrant { + project: scoped.project.clone(), + project_set: scoped.project_set.clone(), + execution_repo: scoped.execution_repo.clone(), + authorized_repositories, + issue: issue.identifier.to_string(), + run_id: scoped.run_id.clone(), + attempt: scoped.attempt, + checkout_generation: runtime_envelope + .as_ref() + .map(|envelope| envelope.checkout_generation.clone()), + target_commit: scoped.target_commit.clone(), + checkout_head: scoped.checkout_head.clone(), + visibility: scoped.visibility, + capabilities: BTreeSet::new(), + }); + memory_grant_requires_fresh_conversation = requires_fresh_conversation; + scoped.token = Some(token.clone()); + } + scoped.authorized_repositories_by_project.clear(); + scoped + }); let memory_grant_requires_fresh_conversation = memory_grant_requires_fresh_conversation || (memory_grant_registry_recovered && worker_memory_env @@ -4049,10 +4328,27 @@ impl RuntimeWorkerBackend { } else { None }; - let terminal_prompt = if let Some(checkout) = runtime_envelope.as_ref() { - let central_procedure = match workflow - .render_prompt(&issue, run.attempt.map(|attempt| attempt.get())) + let parent_repository_instructions = if let Some(parent) = parent_execution.as_ref() { + match workspace_manager + .parent_repository_instructions(parent) + .await { + Ok(instructions) => instructions, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to load parent repository instructions: {error}"), + ); + return; + } + } + } else { + BTreeMap::new() + }; + let central_procedure = + || workflow.render_prompt(&issue, run.attempt.map(|attempt| attempt.get())); + let mut terminal_prompt = if let Some(checkout) = runtime_envelope.as_ref() { + let central_procedure = match central_procedure() { Ok(prompt) => prompt, Err(error) => { report_launch_failure( @@ -4097,11 +4393,11 @@ impl RuntimeWorkerBackend { } else { None }; - runner = runner.with_terminal_prompt(terminal_prompt.clone()); let run_descriptor = RunDescriptor::new(run_id, attempt) .with_normal_retry_count(run.normal_retry_count) .with_repository_binding(run.repository_binding.clone()) - .with_runtime_envelope(runtime_envelope.clone()); + .with_runtime_envelope(runtime_envelope.clone()) + .with_parent_runtime_envelope(parent_runtime_envelope.clone()); let mut initialize_fresh_conversation = false; let mut run_manifest = if recovered { match workspace_manager.load_run_manifest(&ensured.handle).await { @@ -4266,6 +4562,106 @@ impl RuntimeWorkerBackend { } } + if let Some(parent) = parent_execution.as_ref() { + let verified_parent = match if is_parent_retry { + workspace_manager + .open_parent_execution_root_at_for_retry( + &workspace_issue, + parent.handle.workspace_path(), + ) + .await + } else { + workspace_manager + .open_parent_execution_root_at( + &workspace_issue, + parent.handle.workspace_path(), + ) + .await + } { + Ok(parent) => parent, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("parent execution root changed before harness attach: {error}"), + ); + return; + } + }; + if let Some(expected) = parent_runtime_envelope.as_ref() + && let Err(error) = + workspace_manager.verify_parent_runtime_envelope(&verified_parent, expected) + { + report_launch_failure( + &mut launch_tx, + format!("parent runtime envelope changed before harness attach: {error}"), + ); + return; + } + let final_instructions = match workspace_manager + .parent_repository_instructions(&verified_parent) + .await + { + Ok(instructions) => instructions, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!( + "parent repository instructions changed before harness attach: {error}" + ), + ); + return; + } + }; + if parent_repository_instructions != final_instructions { + report_launch_failure( + &mut launch_tx, + "parent repository instructions changed before harness attach".to_owned(), + ); + return; + } + let parent_integration_instructions = + match read_verified_integration_instructions(integration_instructions.as_ref()) + .await + { + Ok(instructions) => instructions, + Err(error) => { + report_launch_failure(&mut launch_tx, error); + return; + } + }; + let central_procedure = match central_procedure() { + Ok(prompt) => prompt, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to render workflow prompt: {error}"), + ); + return; + } + }; + let parent_envelope = parent_runtime_envelope + .as_ref() + .expect("parent execution always creates a runtime envelope"); + terminal_prompt = Some(compose_parent_prompt( + ¢ral_procedure, + &format!( + "Issue: {}\nTitle: {}\nAttempt: {}\nAcceptance and task description:\n{}", + issue.identifier, + issue.title, + attempt, + issue + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + .unwrap_or("No tracker description provided."), + ), + parent_envelope, + parent_integration_instructions.as_deref(), + &parent_repository_instructions, + )); + } + runner = runner.with_terminal_prompt(terminal_prompt.clone()); + if route.dry_run { if let Some(sender) = launch_tx.take() { let _ = sender.send(LaunchReport::Conversation { @@ -5226,6 +5622,7 @@ async fn try_run_codex_stdio_issue( &conversation_id, route, run_manifest.runtime_envelope.clone(), + run_manifest.parent_runtime_envelope.clone(), ) .await { @@ -5277,8 +5674,9 @@ async fn try_run_codex_stdio_issue( )); } }; - if manifest.runtime_envelope.is_some() { + if manifest.runtime_envelope.is_some() || manifest.parent_runtime_envelope.is_some() { run_manifest.runtime_envelope = manifest.runtime_envelope.clone(); + run_manifest.parent_runtime_envelope = manifest.parent_runtime_envelope.clone(); workspace_manager .write_run_manifest(workspace, run_manifest) .await @@ -6744,6 +7142,7 @@ async fn write_codex_conversation_manifest( thread_id: &str, route: &crate::opensymphony_orchestrator::HarnessRouteDecision, runtime_envelope: Option, + parent_runtime_envelope: Option, ) -> Result { let now = chrono::Utc::now(); let conversation_id = ConversationId::new(thread_id.to_string()) @@ -6769,6 +7168,7 @@ async fn write_codex_conversation_manifest( reset_reason: None, runtime_contract_version: Some(CODEX_APP_SERVER_CONTRACT.to_string()), runtime_envelope, + parent_runtime_envelope, codex_archive_state: Some("active".to_string()), last_turn_id: None, active_run_id: None, @@ -6790,6 +7190,9 @@ async fn write_codex_conversation_manifest( if let Some(envelope) = manifest.runtime_envelope.as_mut() { envelope.conversation_binding = Some(manifest.conversation_id.to_string()); } + if let Some(envelope) = manifest.parent_runtime_envelope.as_mut() { + envelope.conversation_binding = Some(manifest.conversation_id.to_string()); + } workspace_manager .write_json_artifact_atomically( workspace, @@ -7068,6 +7471,28 @@ fn report_launch_failure( } } +async fn read_verified_integration_instructions( + instructions: Option<&ResolvedIntegrationInstructions>, +) -> Result, String> { + let Some(instructions) = instructions else { + return Ok(None); + }; + let content = fs::read(&instructions.path).await.map_err(|error| { + format!( + "failed to read project-set integration instructions before harness attach: {error}" + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(&content); + let actual = format!("sha256:{:x}", hasher.finalize()); + if actual != instructions.content_hash { + return Err( + "project-set integration instructions changed before harness attach".to_owned(), + ); + } + Ok(Some(String::from_utf8_lossy(&content).into_owned())) +} + fn pending_launch_failure_detail(result: &Result) -> String { match result { Ok(result) => { @@ -7442,9 +7867,13 @@ mod tests { }; use crate::opensymphony_domain::{ - ConversationId, HarnessInterruptCommand, HarnessInterruptExpectedNextState, - HarnessInterruptReason, IssueId, IssueIdentifier, IssueState, IssueStateCategory, - RetryAttempt, RunAttempt, TrackerIssueStateKind, WorkerId, WorkspaceKey, + CanonicalRepositoryId, ConversationId, HarnessInterruptCommand, + HarnessInterruptExpectedNextState, HarnessInterruptReason, IssueId, IssueIdentifier, + IssueState, IssueStateCategory, RetryAttempt, RunAttempt, TrackerIssueStateKind, WorkerId, + WorkspaceKey, + }; + use crate::opensymphony_orchestrator::{ + HierarchyChildEdge, LeaseKind, LeaseOwner, LeaseRecord, }; use crate::opensymphony_workflow::WorkflowDefinition; use tempfile::TempDir; @@ -7452,6 +7881,37 @@ mod tests { use super::*; + #[tokio::test] + async fn integration_instructions_are_revalidated_from_current_bytes_before_attach() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let path = tempdir.path().join("integration.md"); + fs::write(&path, "verified integration instructions\n") + .expect("integration instructions should be written"); + let mut hasher = Sha256::new(); + hasher.update(fs::read(&path).expect("instruction bytes should be readable")); + let instructions = ResolvedIntegrationInstructions { + path: path.clone(), + content_hash: format!("sha256:{:x}", hasher.finalize()), + }; + + assert_eq!( + read_verified_integration_instructions(Some(&instructions)) + .await + .expect("current instruction bytes should verify") + .as_deref(), + Some("verified integration instructions\n") + ); + + fs::write(&path, "changed by before_run\n") + .expect("integration instructions should be changed"); + assert!( + read_verified_integration_instructions(Some(&instructions)) + .await + .expect_err("post-hook instruction drift must fail before attach") + .contains("changed before harness attach") + ); + } + #[test] fn cleared_superseded_harness_evidence_is_an_empty_sentinel() { assert_eq!( @@ -7462,6 +7922,115 @@ mod tests { assert!(parse_superseded_harness_manifests("{not-json").is_err()); } + #[test] + fn parent_checkout_requests_require_generation_bound_leases_and_scoped_merges() { + let parent_id = IssueId::new("parent-id").expect("parent id"); + let child_a = IssueId::new("child-a").expect("child id"); + let child_b = IssueId::new("child-b").expect("child id"); + let repository_a = CanonicalRepositoryId::new("github:repository:a").expect("repo id"); + let repository_b = CanonicalRepositoryId::new("github:repository:b").expect("repo id"); + let mut snapshot = HierarchySnapshot { + parent_id: parent_id.clone(), + generation: 7, + required_child_edges: vec![ + HierarchyChildEdge { + child_id: child_a.clone(), + child_identifier: IssueIdentifier::new("COE-A").expect("identifier"), + required: true, + }, + HierarchyChildEdge { + child_id: child_b.clone(), + child_identifier: IssueIdentifier::new("COE-B").expect("identifier"), + required: true, + }, + ], + frozen: true, + blocked_reason: None, + eligibility_blocked_reason: None, + dispatched_generation: None, + dispatch_intent_generation: Some(7), + in_flight_generation: None, + dispatch_required_merge_commits: vec![ + RequiredMergeCommit { + repository_id: Some(repository_a.clone()), + commit: "merge-a".to_owned(), + }, + RequiredMergeCommit { + repository_id: Some(repository_b.clone()), + commit: "merge-b".to_owned(), + }, + ], + }; + let owner = LeaseOwner::ancestor(&parent_id); + let resources = [ + LeaseResource { + issue_id: child_a, + repository_id: repository_a, + checkout_generation: "generation-a".to_owned(), + }, + LeaseResource { + issue_id: child_b, + repository_id: repository_b, + checkout_generation: "generation-b".to_owned(), + }, + ]; + let mut state = DurableOrchestratorState::default(); + state.hierarchy.insert(parent_id.clone(), snapshot.clone()); + state.leases = resources + .iter() + .cloned() + .map(|resource| LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource, + owner: owner.clone(), + hierarchy_generation: 7, + acquired_at: 1, + expires_at: None, + released_at: None, + }) + .collect(); + + let requests = parent_checkout_requests(&state, &parent_id, &snapshot) + .expect("leased requests should resolve"); + assert_eq!(requests.len(), 2); + assert!(requests.iter().any(|request| { + request.checkout_generation == "generation-a" + && request.required_merge_commits == ["merge-a"] + })); + state.leases.pop(); + assert!(matches!( + parent_checkout_requests(&state, &parent_id, &snapshot), + Err(CliWorkspaceError::RetryState(reason)) if reason.contains("no active generation-bound ancestor lease") + )); + + state.leases.push(LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: resources[1].clone(), + owner, + hierarchy_generation: 7, + acquired_at: 1, + expires_at: None, + released_at: None, + }); + snapshot.dispatch_required_merge_commits = vec![RequiredMergeCommit { + repository_id: None, + commit: "ambiguous-merge".to_owned(), + }]; + assert!(matches!( + parent_checkout_requests(&state, &parent_id, &snapshot), + Err(CliWorkspaceError::RetryState(reason)) if reason.contains("ambiguous") + )); + + snapshot.required_child_edges.clear(); + snapshot.dispatch_required_merge_commits.clear(); + state.leases.clear(); + assert!( + parent_checkout_requests(&state, &parent_id, &snapshot) + .expect("a parent with no required children should need no checkouts") + .is_empty() + ); + } + fn empty_codex_schema_cache() -> CodexSchemaValidatorCache { Arc::new(AsyncMutex::new(HashMap::new())) } @@ -7543,6 +8112,7 @@ mod tests { reset_reason: None, runtime_contract_version: None, runtime_envelope: None, + parent_runtime_envelope: None, codex_archive_state: None, last_turn_id: None, active_run_id: None, @@ -7787,6 +8357,7 @@ mod tests { workspace_path: PathBuf::from("/workspace/COE-479"), repository_binding: None, runtime_envelope: None, + parent_runtime_envelope: None, attempt: 1, normal_retry_count: 0, pending_retry: false, @@ -7861,6 +8432,7 @@ mod tests { workspace_path: PathBuf::from("/workspace/COE-479--generation-1"), repository_binding: None, runtime_envelope: Some(runtime_envelope.clone()), + parent_runtime_envelope: None, attempt: 1, normal_retry_count: 0, pending_retry: false, @@ -7943,6 +8515,7 @@ mod tests { workspace_path: PathBuf::from("/workspace/COE-479--generation-1"), repository_binding: None, runtime_envelope: Some(runtime_envelope.clone()), + parent_runtime_envelope: None, attempt: 1, normal_retry_count: 0, pending_retry: false, @@ -11387,6 +11960,7 @@ Run the scheduler. state_root: None, memory_catalog_root: None, memory_sources: std::collections::BTreeMap::new(), + integration_instructions: None, project_set_id: None, retain_failed: true, preserve_terminal_workspaces: true, @@ -12132,6 +12706,64 @@ Run the scheduler. } } + #[tokio::test] + async fn codex_manifest_binds_the_same_parent_execution_scope() { + let root = TempDir::new().expect("temporary root should exist"); + let manager = WorkspaceManager::new(WorkspaceManagerConfig { + root: root.path().join("workspaces"), + hooks: HookConfig::default(), + cleanup: CleanupConfig::default(), + }) + .expect("workspace manager should build"); + let issue = sample_issue(); + let workspace = manager + .ensure(&issue_descriptor(&issue)) + .await + .expect("workspace should exist"); + let envelope: ParentRuntimeEnvelope = serde_json::from_value(serde_json::json!({ + "parent_issue_id": issue.id.as_str(), + "parent_identifier": issue.identifier.as_str(), + "run_id": "run-parent-codex", + "attempt": 1, + "hierarchy_generation": 7, + "workspace_path": workspace.handle.workspace_path(), + "checkouts": {}, + "harness": "codex_app_server", + "model_profile": "codex-chatgpt-local-keychain", + "requested_execution_scope": "parent_multi_checkout", + "effective_containment": "trusted_host" + })) + .expect("parent envelope should decode"); + + let manifest = write_codex_conversation_manifest( + &manager, + &workspace.handle, + &issue, + "thread-parent-codex", + &codex_test_route(false), + None, + Some(envelope), + ) + .await + .expect("Codex manifest should persist"); + + assert!(manifest.runtime_envelope.is_none()); + assert_eq!( + manifest + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.as_deref()), + Some("thread-parent-codex") + ); + assert_eq!( + manifest + .parent_runtime_envelope + .as_ref() + .map(|envelope| envelope.requested_execution_scope.as_str()), + Some("parent_multi_checkout") + ); + } + const FAKE_CODEX_SCHEMA: &str = r#"{"$schema":"http://json-schema.org/draft-07/schema#","definitions":{"ClientRequest":{"type":"object","required":["jsonrpc","id","method","params"],"properties":{"jsonrpc":{"const":"2.0"},"id":{"type":"integer"},"method":{"enum":["initialize","thread/start","thread/resume","thread/list","thread/archive","thread/unarchive","turn/start","turn/interrupt"]},"params":{"type":"object"}}}}}"#; #[cfg(unix)] @@ -12627,6 +13259,7 @@ exit 64 reset_reason: None, runtime_contract_version: None, runtime_envelope: None, + parent_runtime_envelope: None, codex_archive_state: None, last_turn_id: None, active_run_id: None, diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index bbb747af3..1970e513d 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -483,6 +483,7 @@ pub(super) struct RunRuntimeConfig { pub(super) memory_catalog_root: Option, pub(super) memory_sources: BTreeMap, pub(super) project_set_id: Option, + pub(super) integration_instructions: Option, pub(super) retain_failed: bool, pub(super) preserve_terminal_workspaces: bool, pub(super) memory: RunMemoryConfig, @@ -503,6 +504,7 @@ pub(super) async fn resolve_runtime_config( central_memory_catalog_root, central_memory_sources, central_project_set_id, + central_integration_instructions, central_repository_instruction_path, central_workflow_front_matter, retry_max_attempts, @@ -530,6 +532,7 @@ pub(super) async fn resolve_runtime_config( central.memory_catalog_root, central.memory_sources, central.project_set_id, + central.integration_instructions, central.repository_instruction_path, Some(central.workflow_front_matter), central.retry_max_attempts, @@ -552,6 +555,7 @@ pub(super) async fn resolve_runtime_config( None, None, None, + None, ) } } @@ -570,6 +574,7 @@ pub(super) async fn resolve_runtime_config( None, None, None, + None, ), }; let central_config = central_workflow_front_matter.is_some(); @@ -711,6 +716,7 @@ pub(super) async fn resolve_runtime_config( memory_catalog_root: central_memory_catalog_root, memory_sources: central_memory_sources, project_set_id: central_project_set_id, + integration_instructions: central_integration_instructions, retain_failed: central_retain_failed.unwrap_or(true), preserve_terminal_workspaces: central_preserve_terminal_workspaces.unwrap_or(true), memory, diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index f8e7871e0..efee95267 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -1127,6 +1127,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { linear_worker_env, ) .with_openhands_conversation_store(runtime.openhands_conversation_store.clone()) + .with_integration_instructions(runtime.integration_instructions.clone()) .with_checkout_credential_envs( runtime .repository_checkouts @@ -2043,6 +2044,7 @@ mod tests { state_root: Some(state.clone()), memory_catalog_root: Some(memory), memory_sources: BTreeMap::new(), + integration_instructions: None, project_set_id: None, retain_failed: true, preserve_terminal_workspaces: true, diff --git a/crates/opensymphony-cli/tests/run.rs b/crates/opensymphony-cli/tests/run.rs index de9b7618c..2fb4405de 100644 --- a/crates/opensymphony-cli/tests/run.rs +++ b/crates/opensymphony-cli/tests/run.rs @@ -509,6 +509,7 @@ async fn seed_recovered_human_review_workspace( reset_reason: None, runtime_contract_version: Some("openhands-sdk-agent-server-v1".to_string()), runtime_envelope: None, + parent_runtime_envelope: None, codex_archive_state: None, last_turn_id: None, active_run_id: None, diff --git a/crates/opensymphony-openhands/src/session.rs b/crates/opensymphony-openhands/src/session.rs index 50b0fd8c9..7e94b1ec5 100644 --- a/crates/opensymphony-openhands/src/session.rs +++ b/crates/opensymphony-openhands/src/session.rs @@ -23,8 +23,8 @@ use crate::opensymphony_workflow::{ Environment, OpenHandsConversationToolConfig, ProcessEnvironment, ResolvedWorkflow, }; use crate::opensymphony_workspace::{ - RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, WorkspaceHandle, - WorkspaceManager, compose_terminal_prompt, + ParentRuntimeEnvelope, RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, + WorkspaceHandle, WorkspaceManager, compose_terminal_prompt, }; use async_trait::async_trait; use chrono::{DateTime, Utc}; @@ -802,6 +802,8 @@ pub struct IssueConversationManifest { pub runtime_contract_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_envelope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_runtime_envelope: Option, /// Codex-only archive state. Missing values from older manifests mean active. #[serde(default, skip_serializing_if = "Option::is_none")] pub codex_archive_state: Option, @@ -885,6 +887,7 @@ impl IssueConversationManifest { reset_reason, runtime_contract_version: Some(RUNTIME_CONTRACT_VERSION.to_string()), runtime_envelope: None, + parent_runtime_envelope: None, codex_archive_state: None, last_turn_id: None, active_run_id: None, @@ -1011,6 +1014,22 @@ impl IssueConversationManifest { } } +fn bind_runtime_envelopes( + manifest: &mut IssueConversationManifest, + run_manifest: &mut RunManifest, +) { + manifest.runtime_envelope = run_manifest.runtime_envelope.clone(); + if let Some(envelope) = manifest.runtime_envelope.as_mut() { + envelope.conversation_binding = Some(manifest.conversation_id.to_string()); + } + run_manifest.runtime_envelope = manifest.runtime_envelope.clone(); + manifest.parent_runtime_envelope = run_manifest.parent_runtime_envelope.clone(); + if let Some(envelope) = manifest.parent_runtime_envelope.as_mut() { + envelope.conversation_binding = Some(manifest.conversation_id.to_string()); + } + run_manifest.parent_runtime_envelope = manifest.parent_runtime_envelope.clone(); +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct IssueSessionContext { pub run_id: String, @@ -2951,11 +2970,7 @@ impl IssueSessionRunner { ); manifest.llm_config_fingerprint = Some(LlmConfigFingerprint::from_llm_config(&request.agent.llm)); - manifest.runtime_envelope = run_manifest.runtime_envelope.clone(); - if let Some(envelope) = manifest.runtime_envelope.as_mut() { - envelope.conversation_binding = Some(manifest.conversation_id.to_string()); - } - run_manifest.runtime_envelope = manifest.runtime_envelope.clone(); + bind_runtime_envelopes(&mut manifest, run_manifest); let pending_manifest_path = pending_conversation_manifest_path(workspace); if let Err(error) = workspace_manager .write_json_artifact_atomically(workspace, &pending_manifest_path, &Some(&manifest)) @@ -3133,6 +3148,10 @@ impl IssueSessionRunner { if let Some(envelope) = failed_manifest.runtime_envelope.as_mut() { envelope.conversation_binding = Some(failed_manifest.conversation_id.to_string()); } + failed_manifest.parent_runtime_envelope = run_manifest.parent_runtime_envelope.clone(); + if let Some(envelope) = failed_manifest.parent_runtime_envelope.as_mut() { + envelope.conversation_binding = Some(failed_manifest.conversation_id.to_string()); + } failed_manifest.apply_transport_diagnostics( self.client.transport_diagnostics().ok().as_ref(), self.client.base_url(), @@ -4726,7 +4745,7 @@ mod tests { use crate::opensymphony_domain::{ BlockerRef, ConversationId, HarnessInterruptExpectedNextState, HarnessInterruptReason, - IssueRef, IssueState, IssueStateCategory, WorkerOutcomeKind, + IssueIdentifier, IssueRef, IssueState, IssueStateCategory, WorkerOutcomeKind, }; use crate::opensymphony_testkit::{FakeOpenHandsConfig, FakeOpenHandsServer}; use axum::{Json, Router, extract::State, routing::post}; @@ -4742,6 +4761,77 @@ mod tests { } } + #[test] + fn openhands_binds_parent_scope_without_creating_a_leaf_envelope() { + let root = tempfile::tempdir().expect("temporary workspace root"); + let path = root.path().join("parent-root"); + std::fs::create_dir_all(&path).expect("parent root should exist"); + let workspace = + WorkspaceHandle::new("parent-id", "COE-PARENT", "parent-COE-PARENT", path.clone()); + let parent_envelope: ParentRuntimeEnvelope = serde_json::from_value(json!({ + "parent_issue_id": "parent-id", + "parent_identifier": "COE-PARENT", + "run_id": "run-parent", + "attempt": 1, + "hierarchy_generation": 7, + "workspace_path": path, + "checkouts": {}, + "harness": "openhands_agent_server", + "model_profile": "default", + "requested_execution_scope": "parent_multi_checkout", + "effective_containment": "trusted_host" + })) + .expect("parent envelope should decode"); + let mut run_manifest = RunManifest::new( + &workspace, + &crate::opensymphony_workspace::RunDescriptor::new("run-parent", 1) + .with_parent_runtime_envelope(Some(parent_envelope)), + ); + let profile = ConversationLaunchProfile { + workspace_kind: "LocalWorkspace".to_owned(), + confirmation_policy_kind: "NeverConfirm".to_owned(), + agent_kind: "Agent".to_owned(), + llm_model: "test-model".to_owned(), + llm_credential_mode: "api_key".to_owned(), + llm_api_key_env: None, + llm_base_url_env: None, + llm_subscription: None, + condenser: None, + agent_tools: None, + agent_include_default_tools: None, + max_iterations: 10, + stuck_detection: true, + llm_api_key_fingerprint: None, + }; + let conversation_id = must(ConversationId::new("conversation-parent")); + let mut manifest = IssueConversationManifest::new( + must(IssueId::new("parent-id")), + must(IssueIdentifier::new("COE-PARENT")), + conversation_id.clone(), + "per_issue", + workspace.openhands_dir(), + Utc::now(), + None, + profile, + &BTreeMap::new(), + ); + + bind_runtime_envelopes(&mut manifest, &mut run_manifest); + + assert!(manifest.runtime_envelope.is_none()); + assert_eq!( + manifest + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.as_deref()), + Some(conversation_id.as_str()) + ); + assert_eq!( + manifest.parent_runtime_envelope, + run_manifest.parent_runtime_envelope + ); + } + #[test] fn launch_profile_constructs_openai_subscription_llm_without_persisting_tokens() { let profile = ConversationLaunchProfile { @@ -5343,6 +5433,7 @@ mod tests { reset_reason: None, runtime_contract_version: None, runtime_envelope: None, + parent_runtime_envelope: None, codex_archive_state: None, last_turn_id: None, active_run_id: None, diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index 89a5e3649..bd2ec9113 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -4,18 +4,20 @@ mod models; mod paths; pub use error::{WorkspaceError, WorkspaceOwnershipConflictDetails}; -pub use manager::{WorkspaceManager, compose_terminal_prompt}; +pub use manager::{WorkspaceManager, compose_parent_prompt, compose_terminal_prompt}; pub use models::{ CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, CleanupOutcome, ConversationManifest, EnsureWorkspaceResult, HookConfig, HookDefinition, HookExecutionRecord, HookExecutionStatus, HookKind, InstructionProvenance, IssueContextArtifact, IssueDescriptor, - IssueLifecycleState, IssueManifest, PromptCaptureDescriptor, PromptCaptureManifest, PromptKind, - RunDescriptor, RunManifest, RunStatus, SSH_AUTH_SOCK_ENV, SessionContextArtifact, - TerminalRuntimeEnvelope, WorkspaceHandle, WorkspaceManagerConfig, - checkout_credential_environment_variables, environment_variable_names_equal, - redact_runtime_diagnostic, + IssueLifecycleState, IssueManifest, ParentCheckoutRequest, ParentChildCheckoutMap, + ParentExecutionManifest, ParentExecutionRoot, ParentIntegrationCheckout, + ParentRetainedCheckout, ParentRuntimeCheckout, ParentRuntimeDescriptor, ParentRuntimeEnvelope, + PromptCaptureDescriptor, PromptCaptureManifest, PromptKind, RunDescriptor, RunManifest, + RunStatus, SSH_AUTH_SOCK_ENV, SessionContextArtifact, TerminalRuntimeEnvelope, WorkspaceHandle, + WorkspaceManagerConfig, checkout_credential_environment_variables, + environment_variable_names_equal, redact_runtime_diagnostic, }; pub use paths::{ - checkout_workspace_key, resolve_path_within_root, sanitize_workspace_key, + checkout_workspace_key, parent_workspace_key, resolve_path_within_root, sanitize_workspace_key, workspace_path_for_root, }; diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index a384004b7..0452df888 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -1,5 +1,6 @@ use std::{ collections::{BTreeMap, BTreeSet}, + ffi::OsString, future::Future, io, path::{Path, PathBuf}, @@ -31,16 +32,18 @@ use super::{ CheckoutManifest, CheckoutRepository, CleanupDecision, CleanupOutcome, ConversationManifest, EnsureWorkspaceResult, HookDefinition, HookExecutionRecord, HookExecutionStatus, HookKind, IssueContextArtifact, IssueDescriptor, IssueLifecycleState, IssueManifest, - PromptCaptureDescriptor, PromptCaptureManifest, RunDescriptor, RunManifest, RunStatus, - SessionContextArtifact, TerminalRuntimeEnvelope, WorkspaceError, WorkspaceHandle, - WorkspaceManagerConfig, WorkspaceOwnershipConflictDetails, + ParentCheckoutRequest, ParentChildCheckoutMap, ParentExecutionManifest, ParentExecutionRoot, + ParentIntegrationCheckout, ParentRetainedCheckout, ParentRuntimeCheckout, + ParentRuntimeDescriptor, ParentRuntimeEnvelope, PromptCaptureDescriptor, PromptCaptureManifest, + RunDescriptor, RunManifest, RunStatus, SessionContextArtifact, TerminalRuntimeEnvelope, + WorkspaceError, WorkspaceHandle, WorkspaceManagerConfig, WorkspaceOwnershipConflictDetails, models::{ AfterCreateBootstrapReceipt, InstructionProvenance, SSH_AUTH_SOCK_ENV, redact_runtime_diagnostic, }, paths::{ - checkout_workspace_key, normalize_absolute_path, resolve_path_within_root, - sanitize_workspace_key, + checkout_workspace_key, normalize_absolute_path, parent_workspace_key, + resolve_path_within_root, sanitize_workspace_key, }, }; use crate::opensymphony_domain::{RepositoryBinding, SafeRemoteFingerprint}; @@ -271,6 +274,1436 @@ impl WorkspaceManager { super::workspace_path_for_root(&self.config.root, issue_identifier) } + pub async fn prepare_parent_execution_root( + &self, + issue: &IssueDescriptor, + hierarchy_generation: u64, + requests: Vec, + ) -> Result { + if hierarchy_generation == 0 || issue.repository_binding.is_some() { + return Err(checkout_verification( + &self.config.root, + "parent preparation requires a repository-neutral issue and a hierarchy generation", + )); + } + let workspace_key = parent_workspace_key(&issue.identifier, &issue.issue_id)?; + let parent_relative = PathBuf::from("parents").join(&workspace_key); + self.reject_symlinked_path_components(&self.config.root, &parent_relative) + .await?; + let parent_base = resolve_path_within_root(&self.config.root, &parent_relative)?; + self.create_directory(&parent_base).await?; + let root = parent_base.join(hierarchy_generation.to_string()); + let parent_pin_path = self + .parent_child_checkout_pin_path(&workspace_key, hierarchy_generation) + .await?; + self.reject_symlinked_workspace_root(&root).await?; + if path_exists(&root).await? { + let parent = self + .open_parent_execution_root_with_changes(issue, hierarchy_generation, &root, true) + .await?; + self.verify_parent_requests(&parent, &requests)?; + return Ok(parent); + } + self.create_directory(&root).await?; + let result = async { + let root = self.canonicalize_path(&root).await?; + ensure_descendant(&self.canonicalize_path(&self.config.root).await?, &root)?; + let handle = WorkspaceHandle::new( + issue.issue_id.clone(), + issue.identifier.clone(), + workspace_key, + root, + ); + match self.execute_hook(HookKind::AfterCreate, &handle).await { + Ok(Some(_)) => self.write_after_create_receipt(issue, &handle).await?, + Ok(None) => {} + Err(failure) => return Err(failure.error), + } + if path_exists(&handle.workspace_path().join(".git")).await? { + return Err(checkout_verification( + handle.workspace_path(), + "parent after_create hook created a root-level Git repository", + )); + } + self.bootstrap_workspace_layout(&handle).await?; + self.create_managed_directory(&handle, &handle.workspace_path().join("evidence")) + .await?; + self.create_managed_directory(&handle, &handle.workspace_path().join("repositories")) + .await?; + + let retained = self.list_all_workspaces().await?; + let mut grouped = BTreeMap::>::new(); + let mut request_keys = BTreeSet::new(); + for request in requests { + if request.issue_id.trim().is_empty() + || request.repository_id.trim().is_empty() + || request.checkout_generation.trim().is_empty() + || request.lease_owner.trim().is_empty() + { + return Err(checkout_verification( + handle.workspace_path(), + "parent checkout request is missing a generation-bound identity or lease owner", + )); + } + if !request_keys.insert(( + request.repository_id.clone(), + request.issue_id.clone(), + request.checkout_generation.clone(), + )) { + return Err(checkout_verification( + handle.workspace_path(), + "parent checkout requests contain a duplicate generation-bound handle", + )); + } + grouped + .entry(request.repository_id.clone()) + .or_default() + .push(request); + } + + let mut repositories = BTreeMap::new(); + for (repository_id, mut repository_requests) in grouped { + repository_requests.sort_by(|left, right| { + left.issue_id + .cmp(&right.issue_id) + .then_with(|| left.checkout_generation.cmp(&right.checkout_generation)) + }); + let repository = self + .checkout_repositories + .get(&repository_id) + .ok_or_else(|| { + checkout_verification( + handle.workspace_path(), + "parent checkout repository policy is unavailable", + ) + })?; + let mut retained_checkouts = Vec::with_capacity(repository_requests.len()); + let mut source = None; + let mut required_merge_commits = BTreeSet::new(); + for request in repository_requests { + let (child_handle, child_issue) = retained + .iter() + .find(|(candidate, manifest)| { + manifest.issue_id == request.issue_id + && candidate.checkout_generation() + == Some(request.checkout_generation.as_str()) + }) + .cloned() + .ok_or_else(|| { + checkout_verification( + handle.workspace_path(), + "retained child checkout generation is unavailable", + ) + })?; + let deadline = checkout_deadline(Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT)); + checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child_handle.workspace_path(), + "validate retained child process configuration", + self.reject_checkout_controlled_process_filters( + child_handle.workspace_path(), + ), + ) + .await?; + let checkout = self + .verify_checkout_with_worker_changes_timeout( + &child_handle, + true, + true, + deadline, + ) + .await?; + if checkout.repository_binding.repository_id().as_str() != repository_id { + return Err(checkout_verification( + child_handle.workspace_path(), + "retained child checkout has the wrong canonical repository", + )); + } + let status = checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child_handle.workspace_path(), + "verify retained child cleanliness", + self.git( + child_handle.workspace_path(), + &["status", "--porcelain", "--untracked-files=all"], + ), + ) + .await?; + if !status.is_empty() { + return Err(checkout_verification( + child_handle.workspace_path(), + "retained child checkout is dirty", + )); + } + let child_branch = checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child_handle.workspace_path(), + "verify retained child branch", + self.git(child_handle.workspace_path(), &["branch", "--show-current"]), + ) + .await?; + let child_head = checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child_handle.workspace_path(), + "verify retained child head", + self.git(child_handle.workspace_path(), &["rev-parse", "HEAD"]), + ) + .await?; + source.get_or_insert((child_handle.clone(), checkout.clone())); + required_merge_commits.extend( + request + .required_merge_commits + .into_iter() + .filter(|commit| !commit.trim().is_empty()), + ); + retained_checkouts.push(ParentRetainedCheckout { + issue_id: request.issue_id, + identifier: child_issue.identifier, + checkout_generation: request.checkout_generation, + lease_owner: request.lease_owner, + child_branch, + child_head, + }); + } + let (source_handle, source_manifest) = source.expect("non-empty repository group"); + self.fetch_parent_target(&source_handle, repository).await?; + let target_ref = format!("refs/remotes/origin/{}", repository.target_branch); + let target_commit = self + .git(source_handle.workspace_path(), &["rev-parse", &target_ref]) + .await?; + for required in &required_merge_commits { + if !self + .git_is_ancestor( + source_handle.workspace_path(), + &["merge-base", "--is-ancestor", required, &target_commit], + ) + .await? + { + return Err(checkout_verification( + source_handle.workspace_path(), + &format!( + "provider merge result {required} is not reachable from target commit {target_commit}" + ), + )); + } + } + + let checkout_handle = parent_checkout_handle(&repository_id, hierarchy_generation); + let relative_path = PathBuf::from("repositories").join(&checkout_handle); + let integration_path = handle.workspace_path().join(&relative_path); + let integration_deadline = + checkout_deadline(Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT)); + let integration_path_text = integration_path.to_str().ok_or_else(|| { + checkout_verification(&integration_path, "integration checkout path is not UTF-8") + })?; + checkout_operation_with_timeout( + checkout_time_remaining(integration_deadline), + source_handle.workspace_path(), + "validate parent worktree process-filter configuration", + self.reject_checkout_controlled_process_filters(source_handle.workspace_path()), + ) + .await?; + checkout_operation_with_timeout( + checkout_time_remaining(integration_deadline), + source_handle.workspace_path(), + "create parent integration worktree", + self.git_with_isolated_hooks_and_global_config( + source_handle.workspace_path(), + &[ + "worktree", + "add", + "--detach", + integration_path_text, + &target_commit, + ], + ), + ) + .await?; + let instruction = checkout_operation_with_timeout( + checkout_time_remaining(integration_deadline), + &integration_path, + "load parent integration instructions", + self.load_instruction_provenance(&integration_path, repository, &target_commit), + ) + .await?; + let record = ParentIntegrationCheckout { + checkout_handle: checkout_handle.clone(), + repository_id: repository_id.clone(), + safe_remote_fingerprint: source_manifest.remote_fingerprint.clone(), + relative_path, + target_branch: repository.target_branch.clone(), + target_commit, + storage_source_generation: source_manifest.generation, + retained_checkouts, + required_merge_commits: required_merge_commits.into_iter().collect(), + instruction, + }; + checkout_operation_with_timeout( + checkout_time_remaining(integration_deadline), + &integration_path, + "verify parent integration worktree", + self.verify_parent_integration_checkout(&handle, &record, &source_handle, false), + ) + .await?; + repositories.insert(repository_id, record); + } + + let now = Utc::now(); + let child_checkout_map = ParentChildCheckoutMap { + schema_version: 1, + hierarchy_generation, + repositories, + }; + let manifest = ParentExecutionManifest { + schema_version: 1, + parent_issue_id: issue.issue_id.clone(), + parent_identifier: issue.identifier.clone(), + hierarchy_generation, + workspace_path: handle.workspace_path().to_path_buf(), + child_checkout_map: PathBuf::from("child-checkouts.json"), + integration_plan: PathBuf::from("integration-plan.md"), + evidence_directory: PathBuf::from("evidence"), + repositories_directory: PathBuf::from("repositories"), + created_at: now, + updated_at: now, + }; + self.write_manifest_atomically( + &handle, + &handle.child_checkouts_path(), + &child_checkout_map, + ) + .await?; + self.write_parent_child_checkout_pin( + &parent_pin_path, + &child_checkout_map, + ) + .await?; + self.write_bytes_artifact_atomically( + &handle, + &handle.workspace_path().join("integration-plan.md"), + render_parent_integration_plan(&child_checkout_map).as_bytes(), + ) + .await?; + self.write_manifest_atomically(&handle, &handle.parent_manifest_path(), &manifest) + .await?; + let issue_manifest = self.upsert_issue_manifest(issue, &handle).await?; + debug_assert_eq!(issue_manifest.workspace_path, handle.workspace_path()); + Ok(ParentExecutionRoot { + handle, + manifest, + child_checkout_map, + created: true, + }) + } + .await; + if result.is_err() { + self.rollback_parent_preparation(&root).await; + self.remove_parent_child_checkout_pin(&parent_pin_path) + .await; + } + result + } + + pub async fn open_parent_execution_root( + &self, + issue: &IssueDescriptor, + hierarchy_generation: u64, + root: &Path, + ) -> Result { + self.open_parent_execution_root_with_changes(issue, hierarchy_generation, root, false) + .await + } + + async fn open_parent_execution_root_with_changes( + &self, + issue: &IssueDescriptor, + hierarchy_generation: u64, + root: &Path, + allow_worker_changes: bool, + ) -> Result { + self.reject_symlinked_path_components( + &self.config.root, + &PathBuf::from("parents") + .join(parent_workspace_key(&issue.identifier, &issue.issue_id)?) + .join(hierarchy_generation.to_string()), + ) + .await?; + let canonical_root = self.canonicalize_path(root).await?; + ensure_descendant( + &self.canonicalize_path(&self.config.root).await?, + &canonical_root, + )?; + let handle = WorkspaceHandle::new( + issue.issue_id.clone(), + issue.identifier.clone(), + parent_workspace_key(&issue.identifier, &issue.issue_id)?, + canonical_root, + ); + let expected_root = self + .config + .root + .join("parents") + .join(handle.workspace_key()) + .join(hierarchy_generation.to_string()); + if self.canonicalize_path(&expected_root).await? != handle.workspace_path() { + return Err(checkout_verification( + handle.workspace_path(), + "parent execution root is not the generation-bound managed path", + )); + } + let manifest = self + .load_manifest::(&handle, &handle.parent_manifest_path()) + .await? + .ok_or_else(|| { + checkout_verification(handle.workspace_path(), "parent manifest is missing") + })?; + let child_checkout_map = self + .load_manifest::(&handle, &handle.child_checkouts_path()) + .await? + .ok_or_else(|| { + checkout_verification(handle.workspace_path(), "child checkout map is missing") + })?; + let pinned_child_checkout_map = self + .load_parent_child_checkout_pin(handle.workspace_key(), hierarchy_generation) + .await? + .ok_or_else(|| { + checkout_verification( + handle.workspace_path(), + "orchestrator-owned parent checkout pin is missing", + ) + })?; + if manifest.schema_version != 1 + || manifest.parent_issue_id != issue.issue_id + || manifest.parent_identifier != issue.identifier + || manifest.hierarchy_generation != hierarchy_generation + || manifest.workspace_path != handle.workspace_path() + || manifest.child_checkout_map != Path::new("child-checkouts.json") + || manifest.integration_plan != Path::new("integration-plan.md") + || manifest.evidence_directory != Path::new("evidence") + || manifest.repositories_directory != Path::new("repositories") + || child_checkout_map.schema_version != 1 + || child_checkout_map.hierarchy_generation != hierarchy_generation + || child_checkout_map != pinned_child_checkout_map + || path_exists(&handle.workspace_path().join(".git")).await? + { + return Err(checkout_verification( + handle.workspace_path(), + "parent execution root identity is inconsistent", + )); + } + if self.config.hooks.after_create.is_some() + && !matches!( + self.inspect_after_create_receipt_state(issue, &handle) + .await?, + ExistingReceiptState::Owned + ) + { + return Err(checkout_verification( + handle.workspace_path(), + "parent after_create hook completion receipt is missing or invalid", + )); + } + for record in child_checkout_map.repositories.values() { + let source = self.validate_parent_retained_checkouts(record).await?; + checkout_operation_with_timeout( + Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT), + handle.workspace_path(), + "verify retained parent integration worktree", + self.verify_parent_integration_checkout( + &handle, + record, + &source, + allow_worker_changes, + ), + ) + .await?; + } + Ok(ParentExecutionRoot { + handle, + manifest, + child_checkout_map, + created: false, + }) + } + + pub async fn open_parent_execution_root_at( + &self, + issue: &IssueDescriptor, + root: &Path, + ) -> Result { + let canonical_root = self.canonicalize_path(root).await?; + let handle = WorkspaceHandle::new( + issue.issue_id.clone(), + issue.identifier.clone(), + parent_workspace_key(&issue.identifier, &issue.issue_id)?, + canonical_root, + ); + let manifest = self + .load_manifest::(&handle, &handle.parent_manifest_path()) + .await? + .ok_or_else(|| { + checkout_verification(handle.workspace_path(), "parent manifest is missing") + })?; + self.open_parent_execution_root(issue, manifest.hierarchy_generation, root) + .await + } + + pub async fn open_parent_execution_root_at_for_retry( + &self, + issue: &IssueDescriptor, + root: &Path, + ) -> Result { + let canonical_root = self.canonicalize_path(root).await?; + let handle = WorkspaceHandle::new( + issue.issue_id.clone(), + issue.identifier.clone(), + parent_workspace_key(&issue.identifier, &issue.issue_id)?, + canonical_root, + ); + let manifest = self + .load_manifest::(&handle, &handle.parent_manifest_path()) + .await? + .ok_or_else(|| { + checkout_verification(handle.workspace_path(), "parent manifest is missing") + })?; + self.open_parent_execution_root_with_changes( + issue, + manifest.hierarchy_generation, + root, + true, + ) + .await + } + + pub async fn resolve_parent_checkout( + &self, + parent: &ParentExecutionRoot, + checkout_handle: &str, + ) -> Result { + let record = parent + .child_checkout_map + .repositories + .values() + .find(|record| record.checkout_handle == checkout_handle) + .ok_or_else(|| { + checkout_verification( + parent.handle.workspace_path(), + "checkout handle is not present in the pinned parent map", + ) + })?; + let source = self.validate_parent_retained_checkouts(record).await?; + checkout_operation_with_timeout( + Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT), + parent.handle.workspace_path(), + "verify resolved parent integration worktree", + self.verify_parent_integration_checkout(&parent.handle, record, &source, true), + ) + .await?; + Ok(parent.handle.workspace_path().join(&record.relative_path)) + } + + fn verify_parent_requests( + &self, + parent: &ParentExecutionRoot, + requests: &[ParentCheckoutRequest], + ) -> Result<(), WorkspaceError> { + let expected = requests + .iter() + .map(|request| { + ( + request.repository_id.as_str(), + request.issue_id.as_str(), + request.checkout_generation.as_str(), + request.lease_owner.as_str(), + ) + }) + .collect::>(); + let observed = parent + .child_checkout_map + .repositories + .values() + .flat_map(|repository| { + repository.retained_checkouts.iter().map(|checkout| { + ( + repository.repository_id.as_str(), + checkout.issue_id.as_str(), + checkout.checkout_generation.as_str(), + checkout.lease_owner.as_str(), + ) + }) + }) + .collect::>(); + let expected_merges = requests + .iter() + .flat_map(|request| { + request + .required_merge_commits + .iter() + .map(|commit| (request.repository_id.as_str(), commit.as_str())) + }) + .filter(|(_, commit)| !commit.trim().is_empty()) + .collect::>(); + let observed_merges = parent + .child_checkout_map + .repositories + .values() + .flat_map(|repository| { + repository + .required_merge_commits + .iter() + .map(|commit| (repository.repository_id.as_str(), commit.as_str())) + }) + .collect::>(); + if expected.len() != requests.len() + || expected != observed + || expected_merges != observed_merges + { + return Err(checkout_verification( + parent.handle.workspace_path(), + "existing parent checkout map does not match the generation-bound preparation request", + )); + } + Ok(()) + } + + pub async fn parent_repository_instructions( + &self, + parent: &ParentExecutionRoot, + ) -> Result, WorkspaceError> { + let mut instructions = BTreeMap::new(); + let mut total_bytes = 0; + for record in parent.child_checkout_map.repositories.values() { + let checkout = self + .resolve_parent_checkout(parent, &record.checkout_handle) + .await?; + if record.instruction.path.as_os_str().is_empty() { + continue; + } + let path = resolve_path_within_root(&checkout, &record.instruction.path)?; + let relative = path + .strip_prefix(parent.handle.workspace_path()) + .map_err(|_| { + checkout_verification(&path, "repository instruction path escapes parent root") + })?; + self.reject_symlinked_path_components(parent.handle.workspace_path(), relative) + .await?; + let (hash, mut bytes) = + read_bounded_instruction_file(&path, &mut total_bytes, true).await?; + let hash = if is_workflow_instruction_path(&record.instruction.path) { + bytes = workflow_body(&bytes); + hash_bytes(&bytes) + } else { + hash + }; + if hash != record.instruction.content_hash { + return Err(checkout_verification( + &path, + "repository instruction hash does not match the parent checkout map", + )); + } + instructions.insert( + record.repository_id.clone(), + String::from_utf8_lossy(&bytes).into_owned(), + ); + } + Ok(instructions) + } + + pub async fn write_parent_runtime_envelope( + &self, + parent: &ParentExecutionRoot, + envelope: &ParentRuntimeEnvelope, + ) -> Result<(), WorkspaceError> { + self.verify_parent_runtime_envelope(parent, envelope)?; + self.write_manifest_atomically( + &parent.handle, + &parent.handle.parent_runtime_envelope_path(), + envelope, + ) + .await + } + + pub fn verify_parent_runtime_envelope( + &self, + parent: &ParentExecutionRoot, + envelope: &ParentRuntimeEnvelope, + ) -> Result<(), WorkspaceError> { + if envelope.parent_issue_id != parent.manifest.parent_issue_id + || envelope.parent_identifier != parent.manifest.parent_identifier + || envelope.hierarchy_generation != parent.manifest.hierarchy_generation + || envelope.workspace_path != parent.handle.workspace_path() + || envelope.checkouts.len() != parent.child_checkout_map.repositories.len() + || envelope.requested_execution_scope != "parent_multi_checkout" + || !matches!( + envelope.effective_containment.as_str(), + "trusted_host" | "workspace_confined" + ) + || parent + .child_checkout_map + .repositories + .values() + .any(|record| { + envelope + .checkouts + .get(&record.checkout_handle) + .is_none_or(|checkout| { + checkout.repository_id != record.repository_id + || checkout.relative_path != record.relative_path + || checkout.target_branch != record.target_branch + || checkout.target_commit != record.target_commit + || checkout.instruction_path != record.instruction.path + || checkout.instruction_hash != record.instruction.content_hash + }) + }) + { + return Err(checkout_verification( + parent.handle.workspace_path(), + "parent runtime envelope does not match the prepared execution root", + )); + } + Ok(()) + } + + pub fn parent_runtime_envelope( + &self, + parent: &ParentExecutionRoot, + descriptor: ParentRuntimeDescriptor, + ) -> ParentRuntimeEnvelope { + let checkouts = parent + .child_checkout_map + .repositories + .values() + .map(|record| { + ( + record.checkout_handle.clone(), + ParentRuntimeCheckout { + repository_id: record.repository_id.clone(), + checkout_handle: record.checkout_handle.clone(), + relative_path: record.relative_path.clone(), + target_branch: record.target_branch.clone(), + target_commit: record.target_commit.clone(), + instruction_path: record.instruction.path.clone(), + instruction_hash: record.instruction.content_hash.clone(), + }, + ) + }) + .collect(); + let (integration_instruction_path, integration_instruction_hash) = descriptor + .integration_instruction + .map_or((None, None), |(path, hash)| (Some(path), Some(hash))); + ParentRuntimeEnvelope { + parent_issue_id: parent.manifest.parent_issue_id.clone(), + parent_identifier: parent.manifest.parent_identifier.clone(), + run_id: descriptor.run_id, + attempt: descriptor.attempt, + hierarchy_generation: parent.manifest.hierarchy_generation, + workspace_path: parent.handle.workspace_path().to_path_buf(), + checkouts, + integration_instruction_path, + integration_instruction_hash, + harness: descriptor.harness, + model_profile: descriptor.model_profile, + model: descriptor.model, + requested_execution_scope: "parent_multi_checkout".to_owned(), + effective_containment: descriptor.effective_containment, + conversation_binding: None, + } + } + + async fn find_retained_checkout( + &self, + repository_id: &str, + generation: &str, + issue_id: Option<&str>, + ) -> Result { + self.list_all_workspaces() + .await? + .into_iter() + .find_map(|(handle, manifest)| { + (handle.checkout_generation() == Some(generation) + && issue_id.is_none_or(|issue_id| manifest.issue_id == issue_id) + && manifest + .repository_binding + .as_ref() + .and_then( + crate::opensymphony_domain::RepositoryBindingOutcome::repository_id, + ) + .is_some_and(|candidate| candidate.as_str() == repository_id)) + .then_some(handle) + }) + .ok_or_else(|| { + checkout_verification( + &self.config.root, + "generation-bound retained checkout handle is stale", + ) + }) + } + + async fn fetch_parent_target( + &self, + source: &WorkspaceHandle, + repository: &CheckoutRepository, + ) -> Result<(), WorkspaceError> { + checkout_operation_with_timeout( + Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT), + source.workspace_path(), + "validate parent fetch transport configuration", + self.reject_checkout_controlled_transport_config(source.workspace_path()), + ) + .await?; + let shallow = self + .git( + source.workspace_path(), + &["rev-parse", "--is-shallow-repository"], + ) + .await? + == "true"; + let mut args = vec!["fetch", "--prune"]; + if shallow { + args.push("--unshallow"); + } + let refspec = format!( + "+refs/heads/{0}:refs/remotes/origin/{0}", + repository.target_branch + ); + args.extend([repository.remote.as_str(), refspec.as_str()]); + self.run_authenticated_git(source.workspace_path(), repository, &args) + .await + } + + async fn reject_checkout_controlled_transport_config( + &self, + checkout: &Path, + ) -> Result<(), WorkspaceError> { + let names = self.checkout_controlled_config_names(checkout).await?; + if let Some(name) = names.iter().find(|name| { + let name = name.trim().to_ascii_lowercase(); + name.starts_with("http.") + || name.starts_with("credential.") + || name == "core.gitproxy" + || name == "core.sshcommand" + || name == "ssh.variant" + || name.starts_with("protocol.") + || (name.starts_with("url.") + && (name.ends_with(".insteadof") || name.ends_with(".pushinsteadof"))) + }) { + return Err(checkout_verification( + checkout, + &format!( + "checkout-controlled Git transport configuration `{}` is not allowed for authenticated parent fetches", + name.trim() + ), + )); + } + Ok(()) + } + + async fn reject_checkout_controlled_process_filters( + &self, + checkout: &Path, + ) -> Result<(), WorkspaceError> { + let names = self.checkout_controlled_config_names(checkout).await?; + if let Some(name) = names.iter().find(|name| { + let name = name.trim().to_ascii_lowercase(); + name.starts_with("filter.") + || name == "core.fsmonitor" + || name == "core.hookspath" + || name.starts_with("includeif.") + }) { + return Err(checkout_verification( + checkout, + &format!( + "checkout-controlled Git process configuration or conditional include `{}` is not allowed for parent worktree creation or verification", + name.trim() + ), + )); + } + Ok(()) + } + + async fn checkout_controlled_config_names( + &self, + checkout: &Path, + ) -> Result, WorkspaceError> { + let local_names = self + .git( + checkout, + &["config", "--local", "--includes", "--name-only", "--list"], + ) + .await?; + let worktree_config_enabled = if local_names.lines().any(|name| { + name.trim() + .eq_ignore_ascii_case("extensions.worktreeConfig") + }) { + matches!( + self.git( + checkout, + &[ + "config", + "--local", + "--includes", + "--bool", + "--get", + "extensions.worktreeConfig", + ], + ) + .await? + .as_str(), + "true" + ) + } else { + false + }; + let mut names = local_names.lines().map(str::to_owned).collect::>(); + if worktree_config_enabled { + let worktree_config_path = self + .git(checkout, &["rev-parse", "--git-path", "config.worktree"]) + .await?; + let worktree_config_path = PathBuf::from(worktree_config_path); + let worktree_config_path = if worktree_config_path.is_absolute() { + worktree_config_path + } else { + checkout.join(worktree_config_path) + }; + if path_exists(&worktree_config_path).await? { + names.extend( + self.git( + checkout, + &[ + "config", + "--worktree", + "--includes", + "--name-only", + "--list", + ], + ) + .await? + .lines() + .map(str::to_owned), + ); + } + } + Ok(names) + } + + async fn run_authenticated_git( + &self, + checkout: &Path, + repository: &CheckoutRepository, + args: &[&str], + ) -> Result<(), WorkspaceError> { + let environment_credential = repository.credential_kind == "environment"; + let ssh_agent_credential = repository.credential_kind == "ssh-agent"; + let environment_credential_value = environment_credential + .then_some(repository.credential_env.as_deref()) + .flatten() + .and_then(std::env::var_os) + .map(|value| value.to_string_lossy().into_owned()); + if (environment_credential && environment_credential_value.is_none()) + || (ssh_agent_credential && !ssh_agent_socket_is_usable()) + || (!environment_credential && !ssh_agent_credential) + { + return Err(WorkspaceError::CheckoutOperation { + operation: "resolve repository credential provider".to_owned(), + path: checkout.to_path_buf(), + detail: if ssh_agent_credential { + "SSH_AUTH_SOCK is unset or does not point to a usable SSH agent socket" + .to_owned() + } else { + "repository credential provider is unavailable".to_owned() + }, + }); + } + + let askpass_path = if environment_credential { + let path = checkout + .join(".opensymphony") + .join(format!("askpass-{}", Uuid::new_v4().simple())); + fs::write( + &path, + b"#!/bin/sh\ncase \"$1\" in\n *Username*) printf '%s\\n' \"$OPENSYMPHONY_CHECKOUT_USERNAME\" ;;\n *) printf '%s\\n' \"$OPENSYMPHONY_CHECKOUT_CREDENTIAL\" ;;\nesac\n", + ) + .await + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "prepare Git credential helper".to_owned(), + path: path.clone(), + detail: source.to_string(), + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)) + .await + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "prepare Git credential helper".to_owned(), + path: path.clone(), + detail: source.to_string(), + })?; + } + Some(path) + } else { + None + }; + + let hooks_directory = tempfile::Builder::new() + .prefix("opensymphony-empty-git-hooks-") + .tempdir() + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "isolate Git hooks".to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + })?; + let mut hooks_config = OsString::from("core.hooksPath="); + hooks_config.push(hooks_directory.path()); + let mut command = Command::new("git"); + command + .arg("-c") + .arg("credential.helper=") + .arg("-c") + .arg("core.fsmonitor=") + .arg("-c") + .arg(hooks_config) + .arg("-C") + .arg(checkout) + .args(args); + for variable in &self.checkout_credential_envs { + command.env_remove(variable); + } + sanitize_git_environment(&mut command); + command + .env_remove("OPENSYMPHONY_CHECKOUT_CREDENTIAL") + .env_remove("GIT_SSH_COMMAND") + .env_remove("GIT_OBJECT_DIRECTORY") + .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES") + .env("GIT_NO_REPLACE_OBJECTS", "1"); + if ssh_agent_credential { + let value = std::env::var_os(SSH_AUTH_SOCK_ENV) + .expect("usable SSH agent socket was validated above"); + command.env(SSH_AUTH_SOCK_ENV, &value).env( + "GIT_SSH_COMMAND", + format!( + "ssh -o IdentityAgent={}", + shell_single_quote(&value.to_string_lossy()) + ), + ); + } + if let Some(value) = environment_credential_value.as_deref() { + command.env("OPENSYMPHONY_CHECKOUT_CREDENTIAL", value); + } + command.env( + "OPENSYMPHONY_CHECKOUT_USERNAME", + git_askpass_username(&repository.provider), + ); + if let Some(path) = askpass_path.as_ref() { + command + .env("GIT_ASKPASS", path) + .env("GIT_TERMINAL_PROMPT", "0"); + } + configure_process_group(&mut command); + command.kill_on_drop(true); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(source) => { + if let Some(path) = askpass_path.as_ref() { + let _ = fs::remove_file(path).await; + } + return Err(WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + }); + } + }; + let process_id = child.id(); + #[cfg(unix)] + let mut process_group_guard = ProcessGroupGuard::new(process_id); + let stdout_task = tokio::spawn(read_child_pipe(child.stdout.take())); + let stderr_task = tokio::spawn(read_child_pipe(child.stderr.take())); + let status = match timeout(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT, child.wait()).await { + Ok(Ok(status)) => status, + Ok(Err(source)) => { + let terminate = terminate_process_tree(&mut child, process_id).await; + let _ = child.wait().await; + #[cfg(unix)] + process_group_guard.disarm(); + let _ = join_child_pipe(stdout_task).await; + let _ = join_child_pipe(stderr_task).await; + if let Some(path) = askpass_path.as_ref() { + let _ = fs::remove_file(path).await; + } + if let Err(error) = terminate { + return Err(WorkspaceError::CheckoutOperation { + operation: "terminate authenticated Git process tree".to_owned(), + path: checkout.to_path_buf(), + detail: error.to_string(), + }); + } + return Err(WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + }); + } + Err(_) => { + let terminate = terminate_process_tree(&mut child, process_id).await; + let _ = child.wait().await; + #[cfg(unix)] + process_group_guard.disarm(); + let _ = join_child_pipe(stdout_task).await; + let _ = join_child_pipe(stderr_task).await; + if let Some(path) = askpass_path.as_ref() { + let _ = fs::remove_file(path).await; + } + if let Err(error) = terminate { + return Err(WorkspaceError::CheckoutOperation { + operation: "terminate authenticated Git process tree".to_owned(), + path: checkout.to_path_buf(), + detail: error.to_string(), + }); + } + return Err(WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: format!( + "operation timed out after {:?}", + RETAINED_CHECKOUT_VERIFICATION_TIMEOUT + ), + }); + } + }; + #[cfg(unix)] + process_group_guard.disarm(); + let _stdout = join_child_pipe(stdout_task).await.map_err(|source| { + WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + } + })?; + let stderr = join_child_pipe(stderr_task).await.map_err(|source| { + WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + } + })?; + if let Some(path) = askpass_path.as_ref() { + let _ = fs::remove_file(path).await; + } + if status.success() { + return Ok(()); + } + let mut detail = redact_runtime_diagnostic(&String::from_utf8_lossy(&stderr)); + if let Some(value) = environment_credential_value.as_deref() { + detail = detail.replace(value, ""); + } + Err(WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail, + }) + } + + async fn verify_parent_integration_checkout( + &self, + parent: &WorkspaceHandle, + record: &ParentIntegrationCheckout, + source: &WorkspaceHandle, + allow_worker_changes: bool, + ) -> Result<(), WorkspaceError> { + let integration = resolve_path_within_root(parent.workspace_path(), &record.relative_path)?; + let repositories = parent.workspace_path().join("repositories"); + self.reject_symlinked_path_components(parent.workspace_path(), Path::new("repositories")) + .await?; + let canonical_parent = self.canonicalize_path(parent.workspace_path()).await?; + let canonical_repositories = self.canonicalize_path(&repositories).await?; + ensure_descendant(&canonical_parent, &canonical_repositories)?; + let canonical_integration = self.canonicalize_path(&integration).await?; + ensure_descendant(&canonical_repositories, &canonical_integration)?; + if integration.file_name().and_then(|name| name.to_str()) + != Some(record.checkout_handle.as_str()) + { + return Err(checkout_verification( + &integration, + "integration checkout path does not match its opaque handle", + )); + } + self.reject_checkout_controlled_process_filters(&integration) + .await?; + let worktree_root = self + .git(&integration, &["rev-parse", "--show-toplevel"]) + .await?; + if self.canonicalize_path(Path::new(&worktree_root)).await? != canonical_integration { + return Err(checkout_verification( + &integration, + "integration checkout Git root is inconsistent", + )); + } + let common = self + .git(&integration, &["rev-parse", "--git-common-dir"]) + .await?; + let common = PathBuf::from(common); + let common = if common.is_absolute() { + common + } else { + integration.join(common) + }; + let source_common = self + .git(source.workspace_path(), &["rev-parse", "--git-common-dir"]) + .await?; + let source_common = PathBuf::from(source_common); + let source_common = if source_common.is_absolute() { + source_common + } else { + source.workspace_path().join(source_common) + }; + if self.canonicalize_path(&common).await? != self.canonicalize_path(&source_common).await? { + return Err(checkout_verification( + &integration, + "integration checkout does not share retained child Git storage", + )); + } + let head = self.git(&integration, &["rev-parse", "HEAD"]).await?; + for required in &record.required_merge_commits { + if !self + .git_is_ancestor( + &integration, + &[ + "merge-base", + "--is-ancestor", + required, + &record.target_commit, + ], + ) + .await? + { + return Err(checkout_verification( + &integration, + &format!( + "provider merge result {required} is not reachable from the pinned target commit" + ), + )); + } + } + if (!allow_worker_changes && head != record.target_commit) + || (allow_worker_changes + && !self + .git_is_ancestor( + &integration, + &["merge-base", "--is-ancestor", &record.target_commit, &head], + ) + .await?) + { + return Err(checkout_verification( + &integration, + "integration checkout does not retain its recorded target commit", + )); + } + if !allow_worker_changes + && !self + .git( + &integration, + &["status", "--porcelain", "--untracked-files=all"], + ) + .await? + .is_empty() + { + return Err(checkout_verification( + &integration, + "integration checkout is dirty", + )); + } + let repository = self + .checkout_repositories + .get(&record.repository_id) + .ok_or_else(|| { + checkout_verification(&integration, "repository policy is unavailable") + })?; + let expected = SafeRemoteFingerprint::from_remote( + &repository.provider, + repository.provider_id.as_deref(), + &repository.remote, + ) + .map_err(|error| checkout_verification(&integration, &error.to_string()))?; + let expected_locator = SafeRemoteFingerprint::from_remote( + &repository.provider, + None, + &repository.remote_locator, + ) + .map_err(|error| checkout_verification(&integration, &error.to_string()))?; + for (kind, args) in [ + ("fetch", ["remote", "get-url", "--all", "origin"].as_slice()), + ( + "push", + ["remote", "get-url", "--all", "--push", "origin"].as_slice(), + ), + ] { + let remotes = self.git(&integration, args).await.map_err(|_| { + checkout_verification( + &integration, + &format!("origin {kind} remote is unavailable"), + ) + })?; + let mut found = false; + for remote in remotes + .lines() + .map(str::trim) + .filter(|remote| !remote.is_empty()) + { + found = true; + if remote_contains_credentials(remote) { + return Err(checkout_verification( + &integration, + "observed remote contains credentials", + )); + } + let actual = SafeRemoteFingerprint::from_remote( + &repository.provider, + repository.provider_id.as_deref(), + remote, + ) + .map_err(|error| checkout_verification(&integration, &error.to_string()))?; + let actual_locator = + SafeRemoteFingerprint::from_remote(&repository.provider, None, remote) + .map_err(|error| checkout_verification(&integration, &error.to_string()))?; + if actual != expected + || actual.as_str() != record.safe_remote_fingerprint + || actual_locator != expected_locator + { + return Err(checkout_verification( + &integration, + "origin remote fingerprint mismatch", + )); + } + } + if !found { + return Err(checkout_verification( + &integration, + &format!("origin {kind} remote is unavailable"), + )); + } + } + if !allow_worker_changes { + let instruction = self + .load_instruction_provenance(&integration, repository, &record.target_commit) + .await?; + if instruction != record.instruction { + return Err(checkout_verification( + &integration, + "repository instruction provenance does not match the parent checkout map", + )); + } + } + Ok(()) + } + + async fn validate_parent_retained_checkouts( + &self, + record: &ParentIntegrationCheckout, + ) -> Result { + let mut source = None; + for retained in &record.retained_checkouts { + let deadline = checkout_deadline(Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT)); + let child = self + .find_retained_checkout( + &record.repository_id, + &retained.checkout_generation, + Some(&retained.issue_id), + ) + .await?; + checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child.workspace_path(), + "validate retained child process configuration", + self.reject_checkout_controlled_process_filters(child.workspace_path()), + ) + .await?; + self.verify_checkout_with_worker_changes_timeout(&child, true, true, deadline) + .await?; + let status = checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child.workspace_path(), + "verify retained child cleanliness", + self.git( + child.workspace_path(), + &["status", "--porcelain", "--untracked-files=all"], + ), + ) + .await?; + if !status.is_empty() { + return Err(checkout_verification( + child.workspace_path(), + "retained child checkout is dirty", + )); + } + let child_head = checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child.workspace_path(), + "verify retained child head", + self.git(child.workspace_path(), &["rev-parse", "HEAD"]), + ) + .await?; + let child_branch = checkout_operation_with_timeout( + checkout_time_remaining(deadline), + child.workspace_path(), + "verify retained child branch", + self.git(child.workspace_path(), &["branch", "--show-current"]), + ) + .await?; + if child_head != retained.child_head || child_branch != retained.child_branch { + return Err(checkout_verification( + child.workspace_path(), + "retained child evidence no longer matches the pinned parent map", + )); + } + if retained.checkout_generation == record.storage_source_generation { + source = Some(child); + } + } + source.ok_or_else(|| { + checkout_verification( + &self.config.root, + "parent checkout map has no retained storage source", + ) + }) + } + + async fn rollback_parent_preparation(&self, root: &Path) { + let repositories = root.join("repositories"); + if let Ok(mut entries) = fs::read_dir(&repositories).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let integration = entry.path(); + if entry + .file_type() + .await + .is_ok_and(|file_type| file_type.is_dir() && !file_type.is_symlink()) + && let Some(integration_text) = integration.to_str() + { + let _ = checkout_operation_with_timeout( + Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT), + &integration, + "roll back parent integration worktree", + self.git( + &integration, + &["worktree", "remove", "--force", integration_text], + ), + ) + .await; + } + } + } + if let Err(error) = fs::remove_dir_all(root).await + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!( + path = %root.display(), + %error, + "failed to roll back incomplete parent execution root" + ); + } + } + pub async fn ensure( &self, issue: &IssueDescriptor, @@ -539,7 +1972,7 @@ impl WorkspaceManager { checkout_time_remaining(checkout_deadline), &staging_path, "verify acquired checkout", - self.verify_git_checkout(&staging_path, binding, repository, true, true), + self.verify_git_checkout(&staging_path, binding, repository, true, true, false), ) .await { @@ -632,7 +2065,14 @@ impl WorkspaceManager { checkout_time_remaining(checkout_deadline), workspace.workspace_path(), "verify checkout after creation hook", - self.verify_git_checkout(workspace.workspace_path(), binding, repository, true, true), + self.verify_git_checkout( + workspace.workspace_path(), + binding, + repository, + true, + true, + false, + ), ) .await { @@ -743,7 +2183,7 @@ impl WorkspaceManager { ) -> Result { let deadline = checkout_deadline(Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT)); let manifest = self - .verify_checkout_with_worker_changes_timeout(workspace, true, deadline) + .verify_checkout_with_worker_changes_timeout(workspace, true, false, deadline) .await?; if manifest.repository_binding != expected.repository_binding || manifest.policy_generation != expected.policy_generation @@ -799,14 +2239,20 @@ impl WorkspaceManager { workspace: &WorkspaceHandle, allow_worker_changes: bool, ) -> Result { - self.verify_checkout_with_worker_changes_timeout(workspace, allow_worker_changes, None) - .await + self.verify_checkout_with_worker_changes_timeout( + workspace, + allow_worker_changes, + false, + None, + ) + .await } async fn verify_checkout_with_worker_changes_timeout( &self, workspace: &WorkspaceHandle, allow_worker_changes: bool, + allow_shallow: bool, checkout_deadline: Option, ) -> Result { self.validate_workspace_handle(workspace).await?; @@ -856,6 +2302,7 @@ impl WorkspaceManager { repository, !allow_worker_changes, false, + allow_shallow, ), ) .await?; @@ -880,9 +2327,11 @@ impl WorkspaceManager { reason: "checkout manifest provenance is inconsistent".to_owned(), }); } + let shallow_state_matches = + facts.shallow == manifest.shallow || (manifest.shallow && !facts.shallow); if (!allow_worker_changes && facts.head != manifest.head) || (!allow_worker_changes && facts.branch != manifest.current_branch) - || facts.shallow != manifest.shallow + || !shallow_state_matches || (!allow_worker_changes && (!facts.clean || manifest.clean != facts.clean)) { return Err(WorkspaceError::CheckoutVerification { @@ -950,7 +2399,7 @@ impl WorkspaceManager { workspace: &WorkspaceHandle, ) -> Result { let deadline = checkout_deadline(Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT)); - self.verify_checkout_with_worker_changes_timeout(workspace, true, deadline) + self.verify_checkout_with_worker_changes_timeout(workspace, true, false, deadline) .await } @@ -1178,6 +2627,7 @@ impl WorkspaceManager { repository, true, true, + false, ) .await { @@ -1296,6 +2746,7 @@ impl WorkspaceManager { .verify_checkout_with_worker_changes_timeout( &handle, allow_worker_changes, + false, checkout_deadline, ) .await @@ -1925,6 +3376,7 @@ impl WorkspaceManager { repository: &CheckoutRepository, enforce_worktree_state: bool, require_remote_head: bool, + allow_shallow: bool, ) -> Result { let inside = self .git(checkout, &["rev-parse", "--is-inside-work-tree"]) @@ -2073,7 +3525,7 @@ impl WorkspaceManager { .git(checkout, &["rev-parse", "--is-shallow-repository"]) .await? == "true"; - if shallow { + if shallow && !allow_shallow { return Err(checkout_verification(checkout, "history is shallow")); } let status = self @@ -2167,9 +3619,97 @@ impl WorkspaceManager { Ok(()) } + async fn git_with_isolated_hooks_and_global_config( + &self, + checkout: &Path, + args: &[&str], + ) -> Result { + let isolated_config = tempfile::Builder::new() + .prefix("opensymphony-isolated-git-") + .tempdir() + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "isolate Git worktree creation".to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + })?; + let mut hooks_config = OsString::from("core.hooksPath="); + hooks_config.push(isolated_config.path()); + let mut command = Command::new("git"); + command + .arg("-c") + .arg("core.fsmonitor=") + .arg("-c") + .arg(hooks_config) + .arg("-C") + .arg(checkout) + .args(args); + for variable in &self.checkout_credential_envs { + command.env_remove(variable); + } + sanitize_git_environment(&mut command); + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env( + "GIT_CONFIG_GLOBAL", + isolated_config.path().join("global.gitconfig"), + ) + .env("GIT_NO_REPLACE_OBJECTS", "1"); + configure_process_group(&mut command); + command + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = command + .spawn() + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + })?; + let process_id = child.id(); + #[cfg(unix)] + let mut process_group_guard = ProcessGroupGuard::new(process_id); + let output = + child + .wait_with_output() + .await + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + })?; + #[cfg(unix)] + process_group_guard.disarm(); + if !output.status.success() { + return Err(WorkspaceError::CheckoutOperation { + operation: args.first().copied().unwrap_or("git").to_owned(), + path: checkout.to_path_buf(), + detail: redact_runtime_diagnostic(&String::from_utf8_lossy(&output.stderr)), + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + } + async fn git(&self, checkout: &Path, args: &[&str]) -> Result { + let hooks_directory = tempfile::Builder::new() + .prefix("opensymphony-empty-git-hooks-") + .tempdir() + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "isolate Git verification hooks".to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + })?; + let mut hooks_config = OsString::from("core.hooksPath="); + hooks_config.push(hooks_directory.path()); let mut command = Command::new("git"); - command.arg("-C").arg(checkout).args(args); + command + .arg("-c") + .arg("core.fsmonitor=") + .arg("-c") + .arg(hooks_config) + .arg("-C") + .arg(checkout) + .args(args); for variable in &self.checkout_credential_envs { command.env_remove(variable); } @@ -2212,8 +3752,22 @@ impl WorkspaceManager { } async fn git_fsck(&self, checkout: &Path) -> Result<(), WorkspaceError> { + let hooks_directory = tempfile::Builder::new() + .prefix("opensymphony-empty-git-hooks-") + .tempdir() + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "isolate Git integrity-check hooks".to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + })?; + let mut hooks_config = OsString::from("core.hooksPath="); + hooks_config.push(hooks_directory.path()); let mut command = Command::new("git"); command + .arg("-c") + .arg("core.fsmonitor=") + .arg("-c") + .arg(hooks_config) .arg("-C") .arg(checkout) .args(["fsck", "--no-dangling"]); @@ -2272,8 +3826,25 @@ impl WorkspaceManager { checkout: &Path, args: &[&str], ) -> Result { + let hooks_directory = tempfile::Builder::new() + .prefix("opensymphony-empty-git-hooks-") + .tempdir() + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "isolate Git ancestry-check hooks".to_owned(), + path: checkout.to_path_buf(), + detail: source.to_string(), + })?; + let mut hooks_config = OsString::from("core.hooksPath="); + hooks_config.push(hooks_directory.path()); let mut command = Command::new("git"); - command.arg("-C").arg(checkout).args(args); + command + .arg("-c") + .arg("core.fsmonitor=") + .arg("-c") + .arg(hooks_config) + .arg("-C") + .arg(checkout) + .args(args); for variable in &self.checkout_credential_envs { command.env_remove(variable); } @@ -3081,6 +4652,54 @@ impl WorkspaceManager { canonical_root.join(".opensymphony-orchestrator-state.json") } + async fn parent_child_checkout_pin_path( + &self, + workspace_key: &str, + hierarchy_generation: u64, + ) -> Result { + let canonical_root = self.canonicalize_path(&self.config.root).await?; + Ok(canonical_root + .join(".opensymphony-parent-pins") + .join(workspace_key) + .join(format!("{hierarchy_generation}.json"))) + } + + async fn write_parent_child_checkout_pin( + &self, + path: &Path, + map: &ParentChildCheckoutMap, + ) -> Result<(), WorkspaceError> { + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root); + self.write_json_artifact_atomically(&handle, path, map) + .await + } + + async fn load_parent_child_checkout_pin( + &self, + workspace_key: &str, + hierarchy_generation: u64, + ) -> Result, WorkspaceError> { + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root); + let path = self + .parent_child_checkout_pin_path(workspace_key, hierarchy_generation) + .await?; + self.load_manifest(&handle, &path).await + } + + async fn remove_parent_child_checkout_pin(&self, path: &Path) { + if let Err(error) = fs::remove_file(path).await + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!( + path = %path.display(), + error = %error, + "failed to remove incomplete parent checkout pin" + ); + } + } + pub async fn load_conversation_manifest( &self, workspace: &WorkspaceHandle, @@ -4195,6 +5814,83 @@ pub fn compose_terminal_prompt( ) } +pub fn compose_parent_prompt( + central_procedure: &str, + task_facts: &str, + envelope: &ParentRuntimeEnvelope, + integration_instructions: Option<&str>, + repository_instructions: &BTreeMap, +) -> String { + let checkout_map = envelope + .checkouts + .values() + .map(|checkout| { + format!( + "- handle={} repository={} path={} branch={} commit={}", + checkout.checkout_handle, + checkout.repository_id, + checkout.relative_path.display(), + checkout.target_branch, + checkout.target_commit + ) + }) + .collect::>() + .join("\n"); + let integration = integration_instructions + .filter(|instructions| !instructions.trim().is_empty()) + .unwrap_or("No project-set integration instructions were configured."); + let repositories = repository_instructions + .iter() + .map(|(repository_id, instructions)| { + format!("### Repository `{repository_id}`\n\n{instructions}") + }) + .collect::>() + .join("\n\n"); + let repositories = if repositories.is_empty() { + "No repository-specific instructions were selected.".to_owned() + } else { + repositories + }; + format!( + "## Central Execution Procedure\n\n{central_procedure}\n\n## Parent Task Facts\n\n{task_facts}\n\n## Verified Child Checkout Map\n\nParent root: {}\nHierarchy generation: {}\n{}\n\n## Project-set Integration Instructions\n\n{}\n\n## Repository Instructions by Canonical ID\n\n{}\n\n## Runtime Capabilities\n\nharness={} cwd={} requested_scope={} containment={}\n", + envelope.workspace_path.display(), + envelope.hierarchy_generation, + checkout_map, + integration, + repositories, + envelope.harness, + envelope.workspace_path.display(), + envelope.requested_execution_scope, + envelope.effective_containment, + ) +} + +fn parent_checkout_handle(repository_id: &str, hierarchy_generation: u64) -> String { + let mut hasher = Sha256::new(); + hasher.update(repository_id.as_bytes()); + hasher.update([0]); + hasher.update(hierarchy_generation.to_le_bytes()); + format!("checkout-{}", &format!("{:x}", hasher.finalize())[..16]) +} + +fn render_parent_integration_plan(map: &ParentChildCheckoutMap) -> String { + let repositories = map + .repositories + .values() + .map(|checkout| { + format!( + "- `{}` via `{}` at `{}`", + checkout.repository_id, checkout.checkout_handle, checkout.target_commit + ) + }) + .collect::>() + .join("\n"); + format!( + "# Parent Integration Plan\n\nHierarchy generation: {}\n\n{}\n", + map.hierarchy_generation, repositories + ) +} + fn is_workflow_instruction_path(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) @@ -4590,8 +6286,18 @@ async fn tracked_instruction_paths( ) -> Result, WorkspaceError> { const MAX_TRACKED_INSTRUCTION_PATHS: usize = 10_000; const MAX_TRACKED_INSTRUCTION_OUTPUT_BYTES: usize = 4 * 1024 * 1024; + let hooks_directory = tempfile::Builder::new() + .prefix("opensymphony-empty-git-hooks-") + .tempdir() + .map_err(|_| checkout_verification(root, "tracked instruction probe failed"))?; + let mut hooks_config = OsString::from("core.hooksPath="); + hooks_config.push(hooks_directory.path()); let mut command = Command::new("git"); command + .arg("-c") + .arg("core.fsmonitor=") + .arg("-c") + .arg(hooks_config) .arg("-C") .arg(root) .args(["ls-files", "--cached", "-z", "--", ":(glob)**/AGENTS.md"]); @@ -4670,7 +6376,7 @@ async fn tracked_instruction_paths( fn path_from_git_bytes(bytes: &[u8]) -> PathBuf { use std::os::unix::ffi::OsStringExt; - PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())) + PathBuf::from(OsString::from_vec(bytes.to_vec())) } #[cfg(not(unix))] @@ -5519,6 +7225,99 @@ mod tests { })); } + #[cfg(unix)] + #[tokio::test] + async fn orchestrator_git_probe_disables_checkout_fsmonitor() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("checkout root should exist"); + let checkout = root.path().join("checkout"); + std::fs::create_dir(&checkout).expect("checkout should exist"); + for args in [ + vec!["init", "--quiet"], + vec!["config", "user.name", "Test User"], + vec!["config", "user.email", "test@example.com"], + ] { + let status = std::process::Command::new("git") + .arg("-C") + .arg(&checkout) + .args(args) + .status() + .expect("git setup should launch"); + assert!(status.success(), "git setup should succeed"); + } + std::fs::write(checkout.join("tracked.txt"), "tracked\n") + .expect("tracked file should exist"); + for args in [vec!["add", "tracked.txt"], vec!["commit", "-m", "initial"]] { + let status = std::process::Command::new("git") + .arg("-C") + .arg(&checkout) + .args(args) + .status() + .expect("git setup should launch"); + assert!(status.success(), "git setup should succeed"); + } + + let marker = root.path().join("fsmonitor-ran.txt"); + let hook_marker = root.path().join("post-index-change-ran.txt"); + let fsmonitor = root.path().join("fsmonitor.sh"); + std::fs::write( + &fsmonitor, + format!( + "#!/bin/sh\nprintf invoked > {}\n", + shell_single_quote(&marker.to_string_lossy()) + ), + ) + .expect("fsmonitor should be written"); + std::fs::set_permissions(&fsmonitor, std::fs::Permissions::from_mode(0o755)) + .expect("fsmonitor should be executable"); + let status = std::process::Command::new("git") + .arg("-C") + .arg(&checkout) + .args(["config", "core.fsmonitor"]) + .arg(&fsmonitor) + .status() + .expect("git config should launch"); + assert!(status.success(), "git config should succeed"); + let hook = checkout.join(".git/hooks/post-index-change"); + std::fs::write( + &hook, + format!( + "#!/bin/sh\nprintf invoked > {}\n", + shell_single_quote(&hook_marker.to_string_lossy()) + ), + ) + .expect("post-index-change hook should be written"); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)) + .expect("post-index-change hook should be executable"); + std::thread::sleep(std::time::Duration::from_millis(1_100)); + std::fs::write(checkout.join("tracked.txt"), "tracked\n") + .expect("tracked file timestamp should change"); + + let manager = WorkspaceManager::new(WorkspaceManagerConfig { + root: root.path().join("workspaces"), + hooks: HookConfig::default(), + cleanup: CleanupConfig::default(), + }) + .expect("workspace manager should be constructed"); + manager + .git(&checkout, &["status", "--porcelain"]) + .await + .expect("orchestrator Git probe should succeed"); + super::tracked_instruction_paths(&checkout, &std::collections::BTreeSet::new()) + .await + .expect("tracked instruction probe should succeed"); + + assert!( + !marker.exists(), + "checkout-controlled fsmonitor must be disabled on the Git command itself" + ); + assert!( + !hook_marker.exists(), + "default checkout hooks must be disabled on the Git command itself" + ); + } + #[test] fn remote_credential_detection_handles_urls_and_scp_locators() { assert!(remote_contains_credentials( diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index 20a42ef83..0ed35ca3e 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -123,6 +123,115 @@ pub struct TerminalRuntimeEnvelope { pub cleanup_intent: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentCheckoutRequest { + pub issue_id: String, + pub repository_id: String, + pub checkout_generation: String, + pub lease_owner: String, + #[serde(default)] + pub required_merge_commits: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentRetainedCheckout { + pub issue_id: String, + pub identifier: String, + pub checkout_generation: String, + pub lease_owner: String, + pub child_branch: String, + pub child_head: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentIntegrationCheckout { + pub checkout_handle: String, + pub repository_id: String, + pub safe_remote_fingerprint: String, + pub relative_path: PathBuf, + pub target_branch: String, + pub target_commit: String, + pub storage_source_generation: String, + pub retained_checkouts: Vec, + pub required_merge_commits: Vec, + pub instruction: InstructionProvenance, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentChildCheckoutMap { + pub schema_version: u32, + pub hierarchy_generation: u64, + pub repositories: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentExecutionManifest { + pub schema_version: u32, + pub parent_issue_id: String, + pub parent_identifier: String, + pub hierarchy_generation: u64, + pub workspace_path: PathBuf, + pub child_checkout_map: PathBuf, + pub integration_plan: PathBuf, + pub evidence_directory: PathBuf, + pub repositories_directory: PathBuf, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentRuntimeCheckout { + pub repository_id: String, + pub checkout_handle: String, + pub relative_path: PathBuf, + pub target_branch: String, + pub target_commit: String, + pub instruction_path: PathBuf, + pub instruction_hash: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentRuntimeEnvelope { + pub parent_issue_id: String, + pub parent_identifier: String, + pub run_id: String, + pub attempt: u32, + pub hierarchy_generation: u64, + pub workspace_path: PathBuf, + pub checkouts: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integration_instruction_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integration_instruction_hash: Option, + pub harness: String, + pub model_profile: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + pub requested_execution_scope: String, + pub effective_containment: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation_binding: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentRuntimeDescriptor { + pub run_id: String, + pub attempt: u32, + pub integration_instruction: Option<(PathBuf, String)>, + pub harness: String, + pub model_profile: String, + pub model: Option, + pub effective_containment: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentExecutionRoot { + pub handle: WorkspaceHandle, + pub manifest: ParentExecutionManifest, + pub child_checkout_map: ParentChildCheckoutMap, + pub created: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckoutManifest { pub schema_version: u32, @@ -528,6 +637,7 @@ pub struct RunDescriptor { pub normal_retry_count: u32, pub repository_binding: Option, pub runtime_envelope: Option, + pub parent_runtime_envelope: Option, } impl RunDescriptor { @@ -538,6 +648,7 @@ impl RunDescriptor { normal_retry_count: 0, repository_binding: None, runtime_envelope: None, + parent_runtime_envelope: None, } } @@ -561,6 +672,14 @@ impl RunDescriptor { self.runtime_envelope = runtime_envelope; self } + + pub fn with_parent_runtime_envelope( + mut self, + parent_runtime_envelope: Option, + ) -> Self { + self.parent_runtime_envelope = parent_runtime_envelope; + self + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -690,6 +809,18 @@ impl WorkspaceHandle { self.metadata_dir().join("checkout.json") } + pub fn parent_manifest_path(&self) -> PathBuf { + self.workspace_path.join("parent-manifest.json") + } + + pub fn child_checkouts_path(&self) -> PathBuf { + self.workspace_path.join("child-checkouts.json") + } + + pub fn parent_runtime_envelope_path(&self) -> PathBuf { + self.metadata_dir().join("parent-runtime.json") + } + pub fn logs_dir(&self) -> PathBuf { self.metadata_dir().join("logs") } @@ -913,6 +1044,8 @@ pub struct RunManifest { pub repository_binding: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_envelope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_runtime_envelope: Option, pub attempt: u32, #[serde(default)] pub normal_retry_count: u32, @@ -951,6 +1084,7 @@ impl RunManifest { workspace_path: workspace.workspace_path().to_path_buf(), repository_binding: run.repository_binding.clone(), runtime_envelope: run.runtime_envelope.clone(), + parent_runtime_envelope: run.parent_runtime_envelope.clone(), attempt: run.attempt, normal_retry_count: run.normal_retry_count, pending_retry: false, diff --git a/crates/opensymphony-workspace/src/paths.rs b/crates/opensymphony-workspace/src/paths.rs index 3411eadfb..8ea75dc9e 100644 --- a/crates/opensymphony-workspace/src/paths.rs +++ b/crates/opensymphony-workspace/src/paths.rs @@ -62,6 +62,20 @@ pub fn checkout_workspace_key( Ok(format!("{display}-{}", &digest[..16])) } +/// Derive a collision-resistant key for a repository-neutral parent workspace. +pub fn parent_workspace_key( + issue_identifier: &str, + issue_id: &str, +) -> Result { + let display = sanitize_workspace_key(issue_identifier)?; + let mut hasher = Sha256::new(); + hasher.update(issue_identifier.as_bytes()); + hasher.update([0]); + hasher.update(issue_id.as_bytes()); + let digest = format!("{:x}", hasher.finalize()); + Ok(format!("parent-{display}-{}", &digest[..16])) +} + pub fn resolve_path_within_root( root: impl AsRef, candidate: impl AsRef, @@ -111,7 +125,10 @@ mod tests { use std::path::PathBuf; use super::WorkspaceError; - use super::{checkout_workspace_key, resolve_path_within_root, sanitize_workspace_key}; + use super::{ + checkout_workspace_key, parent_workspace_key, resolve_path_within_root, + sanitize_workspace_key, + }; #[test] fn sanitizes_documented_examples() { @@ -177,4 +194,14 @@ mod tests { assert_ne!(slash, unicode); assert_ne!(colon, unicode); } + + #[test] + fn parent_keys_keep_sanitized_collisions_distinct() { + let slash = parent_workspace_key("TEAM/A", "parent-one").expect("slash key"); + let underscore = parent_workspace_key("TEAM_A", "parent-two").expect("underscore key"); + + assert!(slash.starts_with("parent-TEAM_A-")); + assert!(underscore.starts_with("parent-TEAM_A-")); + assert_ne!(slash, underscore); + } } diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index ceb5f7adb..c8f219d50 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -7,9 +7,10 @@ use crate::opensymphony_domain::{ use crate::opensymphony_workspace::{ CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, ConversationManifest, HookConfig, HookDefinition, HookExecutionRecord, HookExecutionStatus, HookKind, - IssueContextArtifact, IssueDescriptor, IssueLifecycleState, PromptCaptureDescriptor, - PromptKind, RunDescriptor, RunManifest, RunStatus, SessionContextArtifact, WorkspaceError, - WorkspaceManager, WorkspaceManagerConfig, compose_terminal_prompt, + IssueContextArtifact, IssueDescriptor, IssueLifecycleState, ParentCheckoutRequest, + ParentRuntimeDescriptor, PromptCaptureDescriptor, PromptKind, RunDescriptor, RunManifest, + RunStatus, SessionContextArtifact, WorkspaceError, WorkspaceManager, WorkspaceManagerConfig, + compose_parent_prompt, compose_terminal_prompt, parent_workspace_key, }; use serde_json::json; use tempfile::TempDir; @@ -40,6 +41,22 @@ fn manager_config( } } +fn parent_generation_path( + manager: &WorkspaceManager, + parent: &IssueDescriptor, + generation: u64, +) -> std::path::PathBuf { + manager + .config() + .root + .join("parents") + .join( + parent_workspace_key(&parent.identifier, &parent.issue_id) + .expect("parent workspace key should be valid"), + ) + .join(generation.to_string()) +} + fn git(path: &std::path::Path, args: &[&str]) -> String { let output = Command::new("git") .env("GIT_CONFIG_NOSYSTEM", "1") @@ -56,6 +73,11 @@ fn git(path: &std::path::Path, args: &[&str]) -> String { String::from_utf8_lossy(&output.stdout).trim().to_owned() } +fn configure_git_identity(path: &std::path::Path) { + git(path, &["config", "user.email", "test@example.invalid"]); + git(path, &["config", "user.name", "OpenSymphony Test"]); +} + #[test] fn terminal_prompt_keeps_repository_instructions_in_one_section() { let prompt = compose_terminal_prompt( @@ -72,6 +94,1128 @@ fn terminal_prompt_keeps_repository_instructions_in_one_section() { assert!(!prompt.contains("other repository")); } +fn repository_fixture( + root: &std::path::Path, + name: &str, +) -> (std::path::PathBuf, RepositoryBinding, CheckoutRepository) { + let source = root.join(format!("{name}-source")); + let origin = root.join(format!("{name}.git")); + std::fs::create_dir_all(&source).expect("source should exist"); + std::fs::create_dir_all(&origin).expect("origin should exist"); + git(&source, &["init", "-b", "main"]); + git(&source, &["config", "user.email", "test@example.invalid"]); + git(&source, &["config", "user.name", "OpenSymphony Test"]); + std::fs::write( + source.join("AGENTS.md"), + format!("instructions for {name}\n"), + ) + .expect("instructions should be written"); + std::fs::write(source.join("README.md"), format!("{name}\n")) + .expect("readme should be written"); + git(&source, &["add", "."]); + git(&source, &["commit", "-m", "initial"]); + git(&origin, &["init", "--bare"]); + git( + &source, + &[ + "remote", + "add", + "origin", + origin.to_str().expect("origin path"), + ], + ); + git(&source, &["push", "-u", "origin", "main"]); + let id = + CanonicalRepositoryId::from_remote("local", None, origin.to_str().expect("origin path")) + .expect("repository id should be valid"); + let fingerprint = + SafeRemoteFingerprint::from_remote("local", None, origin.to_str().expect("origin path")) + .expect("fingerprint should be valid"); + ( + source, + RepositoryBinding { + alias: name.to_owned(), + repository: RepositoryIdentity { + id, + safe_remote_fingerprint: fingerprint, + }, + config_generation: "config-1".to_owned(), + inventory_generation: "inventory-1".to_owned(), + }, + CheckoutRepository { + provider: "local".to_owned(), + provider_id: None, + remote_locator: origin.to_str().expect("origin path").to_owned(), + remote: origin.to_str().expect("origin path").to_owned(), + target_branch: "main".to_owned(), + credential_kind: "environment".to_owned(), + credential_reference: None, + credential_env: Some("HOME".to_owned()), + review_credential_env: None, + instructions_path: "AGENTS.md".into(), + policy_generation: "policy-1".to_owned(), + review_profile: "local".to_owned(), + review_provider: "local".to_owned(), + review_policy_generation: "review-1".to_owned(), + required_checks: false, + required_review: false, + merge_method: None, + }, + ) +} + +fn commit_file(source: &std::path::Path, name: &str, contents: &str, message: &str) -> String { + std::fs::write(source.join(name), contents).expect("fixture file should be written"); + git(source, &["add", name]); + git(source, &["commit", "-m", message]); + git(source, &["push", "origin", "main"]); + git(source, &["rev-parse", "HEAD"]) +} + +fn squash_merge_fixture(source: &std::path::Path) -> String { + git(source, &["checkout", "-b", "feature-squash"]); + std::fs::write( + source.join("a1-feature.txt"), + "feature commit replaced by squash\n", + ) + .expect("squash feature should be written"); + git(source, &["add", "a1-feature.txt"]); + git(source, &["commit", "-m", "feature commit for squash"]); + git(source, &["checkout", "main"]); + git(source, &["merge", "--squash", "feature-squash"]); + git(source, &["commit", "-m", "squash merge result"]); + git(source, &["push", "origin", "main"]); + git(source, &["rev-parse", "HEAD"]) +} + +fn rebase_merge_fixture(source: &std::path::Path) -> String { + git(source, &["checkout", "-b", "feature-rebase"]); + std::fs::write( + source.join("a2-feature.txt"), + "feature commit rebased before merge\n", + ) + .expect("rebase feature should be written"); + git(source, &["add", "a2-feature.txt"]); + git(source, &["commit", "-m", "feature commit before rebase"]); + git(source, &["checkout", "main"]); + commit_file( + source, + "base-advance.txt", + "target advanced before rebase\n", + "advance target before rebase", + ); + git(source, &["rebase", "main", "feature-rebase"]); + git(source, &["checkout", "main"]); + git(source, &["merge", "--ff-only", "feature-rebase"]); + git(source, &["push", "origin", "main"]); + git(source, &["rev-parse", "HEAD"]) +} + +#[tokio::test] +async fn parent_execution_root_reuses_three_repositories_and_preserves_children() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let (source_a, mut binding_a, mut repository_a) = + repository_fixture(temp_dir.path(), "repository-a"); + let (source_b, binding_b, mut repository_b) = + repository_fixture(temp_dir.path(), "repository-b"); + let (source_c, binding_c, repository_c) = repository_fixture(temp_dir.path(), "repository-c"); + repository_a.provider_id = Some("repository-a-native-id".to_owned()); + binding_a.repository.safe_remote_fingerprint = SafeRemoteFingerprint::from_remote( + &repository_a.provider, + repository_a.provider_id.as_deref(), + &repository_a.remote, + ) + .expect("native repository fingerprint should be valid"); + repository_b.instructions_path = "WORKFLOW.md".into(); + commit_file( + &source_b, + "WORKFLOW.md", + "---\nname: repository-b\n---\nrepository workflow instructions\n", + "add repository workflow instructions", + ); + let merge_a1 = squash_merge_fixture(&source_a); + let manager = WorkspaceManager::new(manager_config( + &temp_dir.path().join("workspaces"), + HookConfig::default(), + CleanupConfig::default(), + )) + .expect("manager should build") + .with_repository_checkouts(BTreeMap::from([ + (binding_a.repository_id().to_string(), repository_a), + (binding_b.repository_id().to_string(), repository_b), + (binding_c.repository_id().to_string(), repository_c), + ])); + + let mut child_a1 = sample_issue("COE-A1"); + child_a1.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding_a.clone())); + let child_a1 = manager + .ensure(&child_a1) + .await + .expect("first repository-a child should exist"); + configure_git_identity(child_a1.handle.workspace_path()); + std::fs::write( + child_a1.handle.workspace_path().join("child-a1-work.txt"), + "retained child feature commit\n", + ) + .expect("retained child feature should be written"); + git( + child_a1.handle.workspace_path(), + &["add", "child-a1-work.txt"], + ); + git( + child_a1.handle.workspace_path(), + &["commit", "-m", "retain child feature commit"], + ); + #[cfg(unix)] + let (credential_exfil_path, worktree_hook_exfil_path) = { + use std::os::unix::fs::PermissionsExt; + + let credential_path = temp_dir.path().join("retained-hook-credential.txt"); + let reference_hook = child_a1 + .handle + .workspace_path() + .join(".git/hooks/reference-transaction"); + std::fs::write( + &reference_hook, + format!( + "#!/bin/sh\nif [ -n \"$OPENSYMPHONY_CHECKOUT_CREDENTIAL\" ]; then printf '%s' \"$OPENSYMPHONY_CHECKOUT_CREDENTIAL\" > {}; fi\n", + shell_quote(&credential_path) + ), + ) + .expect("retained checkout hook should be written"); + std::fs::set_permissions(&reference_hook, std::fs::Permissions::from_mode(0o755)) + .expect("retained checkout hook should be executable"); + let worktree_path = temp_dir.path().join("retained-worktree-hook.txt"); + let post_checkout_hook = child_a1 + .handle + .workspace_path() + .join(".git/hooks/post-checkout"); + std::fs::write( + &post_checkout_hook, + format!( + "#!/bin/sh\nprintf invoked > {}\n", + shell_quote(&worktree_path) + ), + ) + .expect("retained post-checkout hook should be written"); + std::fs::set_permissions(&post_checkout_hook, std::fs::Permissions::from_mode(0o755)) + .expect("retained post-checkout hook should be executable"); + (credential_path, worktree_path) + }; + let child_a1_head = git(child_a1.handle.workspace_path(), &["rev-parse", "HEAD"]); + let merge_a2 = rebase_merge_fixture(&source_a); + + let mut child_a2 = sample_issue("COE-A2"); + child_a2.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding_a.clone())); + let child_a2 = manager + .ensure(&child_a2) + .await + .expect("second repository-a child should exist"); + configure_git_identity(child_a2.handle.workspace_path()); + let mut child_b = sample_issue("COE-B"); + child_b.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding_b.clone())); + let child_b = manager + .ensure(&child_b) + .await + .expect("repository-b child should exist"); + let mut child_c = sample_issue("COE-C"); + child_c.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding_c.clone())); + let child_c = manager + .ensure(&child_c) + .await + .expect("repository-c child should exist"); + let child_c_git_dir = child_c.handle.workspace_path().join(".git"); + std::fs::write( + child_c_git_dir.join("shallow"), + format!( + "{}\n", + git(child_c.handle.workspace_path(), &["rev-parse", "HEAD"]) + ), + ) + .expect("shallow boundary should be written"); + let child_c_manifest_path = child_c.handle.checkout_manifest_path(); + let mut child_c_manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(&child_c_manifest_path).expect("child manifest should be readable"), + ) + .expect("child manifest should decode"); + child_c_manifest["shallow"] = serde_json::Value::Bool(true); + std::fs::write( + &child_c_manifest_path, + serde_json::to_vec_pretty(&child_c_manifest).expect("child manifest should encode"), + ) + .expect("child manifest should be updated for the shallow fixture"); + assert_eq!( + git( + child_c.handle.workspace_path(), + &["rev-parse", "--is-shallow-repository"] + ), + "true" + ); + + let child_snapshot = [ + child_a1.handle.clone(), + child_a2.handle.clone(), + child_b.handle.clone(), + child_c.handle.clone(), + ] + .map(|handle| { + ( + handle.clone(), + git(handle.workspace_path(), &["rev-parse", "HEAD"]), + git( + handle.workspace_path(), + &["status", "--porcelain", "--untracked-files=all"], + ), + std::fs::read(handle.checkout_manifest_path()) + .expect("child manifest should be readable"), + ) + }); + let mut parent = sample_issue("COE-PARENT"); + parent.issue_id = "parent-id".to_owned(); + let requests = vec![ + ParentCheckoutRequest { + issue_id: child_a1.handle.issue_id().to_owned(), + repository_id: binding_a.repository_id().to_string(), + checkout_generation: child_a1 + .handle + .checkout_generation() + .expect("generation") + .to_owned(), + lease_owner: "ancestor-integration:parent-id".to_owned(), + required_merge_commits: vec![merge_a1], + }, + ParentCheckoutRequest { + issue_id: child_a2.handle.issue_id().to_owned(), + repository_id: binding_a.repository_id().to_string(), + checkout_generation: child_a2 + .handle + .checkout_generation() + .expect("generation") + .to_owned(), + lease_owner: "ancestor-integration:parent-id".to_owned(), + required_merge_commits: vec![merge_a2.clone()], + }, + ParentCheckoutRequest { + issue_id: child_b.handle.issue_id().to_owned(), + repository_id: binding_b.repository_id().to_string(), + checkout_generation: child_b + .handle + .checkout_generation() + .expect("generation") + .to_owned(), + lease_owner: "ancestor-integration:parent-id".to_owned(), + required_merge_commits: vec![git(&source_b, &["rev-parse", "HEAD"])], + }, + ParentCheckoutRequest { + issue_id: child_c.handle.issue_id().to_owned(), + repository_id: binding_c.repository_id().to_string(), + checkout_generation: child_c + .handle + .checkout_generation() + .expect("generation") + .to_owned(), + lease_owner: "ancestor-integration:parent-id".to_owned(), + required_merge_commits: vec![git(&source_c, &["rev-parse", "HEAD"])], + }, + ]; + let mut stale_requests = requests.clone(); + stale_requests[0].checkout_generation = "stale-generation".to_owned(); + assert!(matches!( + manager.prepare_parent_execution_root(&parent, 4, stale_requests).await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("generation is unavailable") + )); + + std::fs::write(child_b.handle.workspace_path().join("dirty.txt"), "dirty\n") + .expect("dirty marker should be written"); + assert!(matches!( + manager.prepare_parent_execution_root(&parent, 5, requests.clone()).await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("dirty") + )); + std::fs::remove_file(child_b.handle.workspace_path().join("dirty.txt")) + .expect("dirty marker should be removed"); + assert!(!parent_generation_path(&manager, &parent, 5).exists()); + + let expected_remote = git( + child_c.handle.workspace_path(), + &["remote", "get-url", "origin"], + ); + git( + child_c.handle.workspace_path(), + &[ + "remote", + "set-url", + "origin", + source_b.to_str().expect("wrong remote path"), + ], + ); + assert!(matches!( + manager.prepare_parent_execution_root(&parent, 6, requests.clone()).await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("remote fingerprint") + )); + git( + child_c.handle.workspace_path(), + &["remote", "set-url", "origin", &expected_remote], + ); + + git( + child_a1.handle.workspace_path(), + &["config", "http.proxy", "http://127.0.0.1:9"], + ); + git( + child_a1.handle.workspace_path(), + &["config", "http.sslVerify", "false"], + ); + assert!(matches!( + manager.prepare_parent_execution_root(&parent, 7, requests.clone()).await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("checkout-controlled Git transport configuration") + )); + assert!( + !parent_generation_path(&manager, &parent, 7).exists(), + "a rejected transport configuration must roll back the partial parent root" + ); + #[cfg(unix)] + assert!( + !credential_exfil_path.exists(), + "checkout-controlled transport configuration must be rejected before credential exposure" + ); + git( + child_a1.handle.workspace_path(), + &["config", "--unset-all", "http.proxy"], + ); + git( + child_a1.handle.workspace_path(), + &["config", "--unset-all", "http.sslVerify"], + ); + git( + child_a1.handle.workspace_path(), + &[ + "config", + "filter.opensymphony-test.process", + "unused-process-filter", + ], + ); + assert!(matches!( + manager.prepare_parent_execution_root(&parent, 7, requests.clone()).await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("process configuration") + )); + assert!(!parent_generation_path(&manager, &parent, 7).exists()); + git( + child_a1.handle.workspace_path(), + &["config", "--unset-all", "filter.opensymphony-test.process"], + ); + + let repeat_requests = requests.clone(); + let prepared = manager + .prepare_parent_execution_root(&parent, 5, requests) + .await + .expect("same generation should retry after incomplete preparation is rolled back"); + + #[cfg(unix)] + assert!( + !credential_exfil_path.exists(), + "authenticated parent fetches must not run retained-checkout hooks" + ); + #[cfg(unix)] + assert!( + !worktree_hook_exfil_path.exists(), + "parent worktree creation must not run retained-checkout hooks" + ); + + assert!(!prepared.handle.workspace_path().join(".git").exists()); + assert_eq!(prepared.child_checkout_map.repositories.len(), 3); + let repository_a = prepared + .child_checkout_map + .repositories + .get(binding_a.repository_id().as_str()) + .expect("repository-a checkout should exist"); + assert_eq!(repository_a.retained_checkouts.len(), 2); + assert_eq!(repository_a.target_commit, merge_a2); + for (handle, head, status, manifest) in child_snapshot { + assert_eq!(git(handle.workspace_path(), &["rev-parse", "HEAD"]), head); + assert_eq!( + git( + handle.workspace_path(), + &["status", "--porcelain", "--untracked-files=all"], + ), + status + ); + assert_eq!( + std::fs::read(handle.checkout_manifest_path()) + .expect("child manifest should remain readable"), + manifest + ); + } + for checkout in prepared.child_checkout_map.repositories.values() { + let path = manager + .resolve_parent_checkout(&prepared, &checkout.checkout_handle) + .await + .expect("opaque checkout handle should resolve"); + assert_eq!(git(&path, &["rev-parse", "HEAD"]), checkout.target_commit); + assert!( + path.join(".git").is_file(), + "a linked worktree uses a .git file" + ); + } + assert_eq!( + git(child_a1.handle.workspace_path(), &["rev-parse", "HEAD"]), + child_a1_head + ); + assert_eq!( + git( + child_c.handle.workspace_path(), + &["rev-parse", "--is-shallow-repository"] + ), + "false" + ); + manager + .verify_checkout_for_retry(&child_c.handle) + .await + .expect("a parent-deepened child should remain reusable for its own retry"); + manager + .prepare_parent_execution_root(&parent, 8, repeat_requests.clone()) + .await + .expect("a deepened retained child should support a later parent generation"); + assert!(matches!( + manager.resolve_parent_checkout(&prepared, "../arbitrary").await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("not present") + )); + + std::fs::write( + child_a2 + .handle + .workspace_path() + .join("late-child-drift.txt"), + "must be detected\n", + ) + .expect("late child drift marker should be written"); + let child_drift_error = manager + .open_parent_execution_root_at(&parent, prepared.handle.workspace_path()) + .await + .expect_err("a dirty non-source child must invalidate the parent root"); + assert!( + child_drift_error.to_string().contains("dirty"), + "unexpected child-drift error: {child_drift_error}" + ); + std::fs::remove_file( + child_a2 + .handle + .workspace_path() + .join("late-child-drift.txt"), + ) + .expect("late child drift marker should be removed"); + + let child_a2_head = git(child_a2.handle.workspace_path(), &["rev-parse", "HEAD"]); + std::fs::write( + child_a2 + .handle + .workspace_path() + .join("late-child-commit.txt"), + "committed drift must be detected\n", + ) + .expect("late child commit should be written"); + git( + child_a2.handle.workspace_path(), + &["add", "late-child-commit.txt"], + ); + git( + child_a2.handle.workspace_path(), + &["commit", "-m", "late child drift"], + ); + let child_commit_drift_error = manager + .open_parent_execution_root_at(&parent, prepared.handle.workspace_path()) + .await + .expect_err("committed non-source child drift must invalidate the parent root"); + assert!( + child_commit_drift_error + .to_string() + .contains("pinned parent map"), + "unexpected child-commit-drift error: {child_commit_drift_error}" + ); + git( + child_a2.handle.workspace_path(), + &["reset", "--hard", &child_a2_head], + ); + + let integration = prepared + .handle + .workspace_path() + .join(&repository_a.relative_path); + git( + &integration, + &["config", "extensions.worktreeConfig", "true"], + ); + git( + &integration, + &[ + "config", + "--worktree", + "remote.origin.pushurl", + source_b.to_str().expect("wrong push remote path"), + ], + ); + let remote_drift_error = manager + .open_parent_execution_root_at(&parent, prepared.handle.workspace_path()) + .await + .expect_err("a worktree-specific wrong push remote must invalidate the parent root"); + assert!( + remote_drift_error + .to_string() + .contains("remote fingerprint"), + "unexpected integration remote error: {remote_drift_error}" + ); + git( + &integration, + &[ + "config", + "--worktree", + "--unset-all", + "remote.origin.pushurl", + ], + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let marker = temp_dir.path().join("integration-fsmonitor-ran.txt"); + let fsmonitor = temp_dir.path().join("integration-fsmonitor.sh"); + std::fs::write( + &fsmonitor, + format!("#!/bin/sh\nprintf invoked > {}\n", shell_quote(&marker)), + ) + .expect("integration fsmonitor should be written"); + std::fs::set_permissions(&fsmonitor, std::fs::Permissions::from_mode(0o755)) + .expect("integration fsmonitor should be executable"); + git( + &integration, + &[ + "config", + "--worktree", + "core.fsmonitor", + fsmonitor.to_str().expect("fsmonitor path"), + ], + ); + let fsmonitor_error = manager + .open_parent_execution_root_at(&parent, prepared.handle.workspace_path()) + .await + .expect_err("worktree-specific fsmonitor must invalidate the parent root"); + assert!( + fsmonitor_error.to_string().contains("core.fsmonitor"), + "unexpected integration fsmonitor error: {fsmonitor_error}" + ); + assert!( + !marker.exists(), + "integration fsmonitor must not run during parent probes" + ); + git( + &integration, + &["config", "--worktree", "--unset-all", "core.fsmonitor"], + ); + } + std::fs::write( + integration.join("parent-change.txt"), + "preserve across retry\n", + ) + .expect("parent change should be written"); + let parent_drift_error = manager + .open_parent_execution_root_at(&parent, prepared.handle.workspace_path()) + .await + .expect_err("strict reopen must reject dirty parent integration work"); + assert!( + parent_drift_error.to_string().contains("dirty"), + "unexpected parent-drift error: {parent_drift_error}" + ); + manager + .open_parent_execution_root_at_for_retry(&parent, prepared.handle.workspace_path()) + .await + .expect("retry attachment should preserve parent integration changes"); + let retried_preparation = manager + .prepare_parent_execution_root(&parent, 5, repeat_requests.clone()) + .await + .expect("scheduler preparation should preserve parent integration changes on retry"); + assert!(!retried_preparation.created); + assert!(integration.join("parent-change.txt").exists()); + assert_eq!( + manager + .resolve_parent_checkout(&prepared, &repository_a.checkout_handle) + .await + .expect("handle resolution should permit parent-owned changes"), + integration + ); + std::fs::remove_file(integration.join("parent-change.txt")) + .expect("parent change should be removed before manifest mismatch validation"); + + let child_checkout_map_path = prepared.handle.child_checkouts_path(); + let original_child_checkout_map = + std::fs::read(&child_checkout_map_path).expect("child checkout map should remain readable"); + let mut tampered_child_checkout_map: serde_json::Value = + serde_json::from_slice(&original_child_checkout_map) + .expect("child checkout map should decode"); + let older_target = git(&integration, &["rev-parse", "HEAD^"]); + tampered_child_checkout_map["repositories"][binding_a.repository_id().as_str()]["target_commit"] = + serde_json::Value::String(older_target.clone()); + std::fs::write( + &child_checkout_map_path, + serde_json::to_vec_pretty(&tampered_child_checkout_map) + .expect("tampered child checkout map should encode"), + ) + .expect("tampered child checkout map should be writable"); + git(&integration, &["reset", "--hard", &older_target]); + let repin_error = manager + .open_parent_execution_root_at_for_retry(&parent, prepared.handle.workspace_path()) + .await + .expect_err("an agent-writable target pin must not authorize an older integration head"); + assert!( + repin_error.to_string().contains("identity is inconsistent"), + "unexpected parent-map repin error: {repin_error}" + ); + std::fs::write(&child_checkout_map_path, original_child_checkout_map) + .expect("trusted child checkout map should be restored"); + git( + &integration, + &["reset", "--hard", &repository_a.target_commit], + ); + + let mut changed_requests = prepared + .child_checkout_map + .repositories + .values() + .flat_map(|repository| { + repository + .retained_checkouts + .iter() + .map(|checkout| ParentCheckoutRequest { + issue_id: checkout.issue_id.clone(), + repository_id: repository.repository_id.clone(), + checkout_generation: checkout.checkout_generation.clone(), + lease_owner: checkout.lease_owner.clone(), + required_merge_commits: repository.required_merge_commits.clone(), + }) + }) + .collect::>(); + changed_requests[0].lease_owner = "ancestor-integration:other-parent".to_owned(); + assert!(matches!( + manager.prepare_parent_execution_root(&parent, 5, changed_requests).await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("does not match") + )); + + let repository_instructions = manager + .parent_repository_instructions(&prepared) + .await + .expect("repository instructions should load"); + assert_eq!( + repository_instructions + .get(binding_b.repository_id().as_str()) + .map(String::as_str), + Some("repository workflow instructions\n") + ); + let openhands = manager.parent_runtime_envelope( + &prepared, + ParentRuntimeDescriptor { + run_id: "run-parent".to_owned(), + attempt: 1, + integration_instruction: None, + harness: "openhands_agent_server".to_owned(), + model_profile: "default".to_owned(), + model: None, + effective_containment: "trusted_host".to_owned(), + }, + ); + let codex = manager.parent_runtime_envelope( + &prepared, + ParentRuntimeDescriptor { + run_id: "run-parent".to_owned(), + attempt: 1, + integration_instruction: None, + harness: "codex_app_server".to_owned(), + model_profile: "default".to_owned(), + model: None, + effective_containment: "trusted_host".to_owned(), + }, + ); + assert_eq!(openhands.workspace_path, codex.workspace_path); + assert_eq!(openhands.checkouts, codex.checkouts); + assert_eq!(openhands.requested_execution_scope, "parent_multi_checkout"); + assert_eq!(codex.effective_containment, "trusted_host"); + let prompt = compose_parent_prompt( + "generic lifecycle policy", + "acceptance criteria", + &codex, + Some("project-set instructions"), + &repository_instructions, + ); + assert!(prompt.contains(binding_a.repository_id().as_str())); + assert!(prompt.contains("project-set instructions")); + assert!(!prompt.contains("frontend repository")); + + #[cfg(unix)] + { + let repositories = prepared.handle.workspace_path().join("repositories"); + let outside = temp_dir.path().join("outside-parent-root"); + std::fs::rename(&repositories, &outside) + .expect("repository worktrees should move outside the parent root"); + symlink(&outside, &repositories).expect("repositories symlink should be created"); + assert!(matches!( + manager + .resolve_parent_checkout(&prepared, &repository_a.checkout_handle) + .await, + Err(WorkspaceError::PathEscape { .. }) + | Err(WorkspaceError::ManagedPathSymlink { .. }) + | Err(WorkspaceError::InstructionPathEscape { .. }) + | Err(WorkspaceError::CheckoutVerification { .. }) + )); + } +} + +#[tokio::test] +async fn parent_execution_root_supports_no_required_child_checkouts() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let manager = WorkspaceManager::new(manager_config( + &temp_dir.path().join("workspaces"), + HookConfig { + after_create: Some(HookDefinition::shell(parent_after_create_once_command())), + ..HookConfig::default() + }, + CleanupConfig::default(), + )) + .expect("manager should build"); + let mut parent = sample_issue("COE-CANCELED-PARENT"); + parent.issue_id = "canceled-parent-id".to_owned(); + + let prepared = manager + .prepare_parent_execution_root(&parent, 1, Vec::new()) + .await + .expect("a parent with no required child checkouts should still get an execution root"); + + assert!(!prepared.handle.workspace_path().join(".git").exists()); + assert!(prepared.child_checkout_map.repositories.is_empty()); + assert!( + prepared + .handle + .workspace_path() + .join("repositories") + .is_dir() + ); + assert_eq!( + std::fs::read_to_string( + prepared + .handle + .workspace_path() + .join("parent-after-create.txt") + ) + .expect("parent after_create output should exist") + .trim(), + "created" + ); + let after_create_receipt = prepared + .handle + .workspace_path() + .join(".opensymphony.after_create.json"); + assert!(after_create_receipt.is_file()); + + let reopened = manager + .prepare_parent_execution_root(&parent, 1, Vec::new()) + .await + .expect("a completed parent after_create hook must not rerun on reuse"); + assert!(!reopened.created); + + std::fs::remove_file(after_create_receipt) + .expect("parent after_create receipt should be removable for validation"); + assert!(matches!( + manager + .open_parent_execution_root_at(&parent, prepared.handle.workspace_path()) + .await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("after_create") + )); +} + +#[tokio::test] +async fn parent_execution_roots_keep_colliding_sanitized_identifiers_distinct() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let manager = WorkspaceManager::new(manager_config( + &temp_dir.path().join("workspaces"), + HookConfig::default(), + CleanupConfig::default(), + )) + .expect("manager should build"); + let mut slash_parent = sample_issue("TEAM/A"); + slash_parent.issue_id = "parent-one".to_owned(); + let mut underscore_parent = sample_issue("TEAM_A"); + underscore_parent.issue_id = "parent-two".to_owned(); + + let slash = manager + .prepare_parent_execution_root(&slash_parent, 1, Vec::new()) + .await + .expect("slash parent should prepare"); + let underscore = manager + .prepare_parent_execution_root(&underscore_parent, 1, Vec::new()) + .await + .expect("underscore parent should prepare independently"); + + assert_ne!( + slash.handle.workspace_key(), + underscore.handle.workspace_key() + ); + assert_ne!( + slash.handle.workspace_path(), + underscore.handle.workspace_path() + ); + assert_eq!(slash.manifest.parent_issue_id, "parent-one"); + assert_eq!(underscore.manifest.parent_issue_id, "parent-two"); +} + +#[tokio::test] +async fn parent_execution_root_rolls_back_after_create_failure() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let manager = WorkspaceManager::new(manager_config( + &temp_dir.path().join("workspaces"), + HookConfig { + after_create: Some(HookDefinition::shell(failing_parent_after_create_command())), + ..HookConfig::default() + }, + CleanupConfig::default(), + )) + .expect("manager should build"); + let mut parent = sample_issue("COE-FAILED-PARENT"); + parent.issue_id = "failed-parent-id".to_owned(); + + assert!(matches!( + manager + .prepare_parent_execution_root(&parent, 1, Vec::new()) + .await, + Err(WorkspaceError::HookFailed { .. }) + )); + assert!(!parent_generation_path(&manager, &parent, 1).exists()); +} + +#[tokio::test] +async fn parent_execution_root_rolls_back_when_after_create_initializes_git() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let manager = WorkspaceManager::new(manager_config( + &temp_dir.path().join("workspaces"), + HookConfig { + after_create: Some(HookDefinition::shell("git init -b parent-hook")), + ..HookConfig::default() + }, + CleanupConfig::default(), + )) + .expect("manager should build"); + let mut parent = sample_issue("COE-GIT-PARENT"); + parent.issue_id = "git-parent-id".to_owned(); + + assert!(matches!( + manager + .prepare_parent_execution_root(&parent, 1, Vec::new()) + .await, + Err(WorkspaceError::CheckoutVerification { reason, .. }) + if reason.contains("root-level Git repository") + )); + assert!(!parent_generation_path(&manager, &parent, 1).exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn parent_preparation_rejects_child_controlled_fsmonitor_before_execution() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().expect("temp dir should exist"); + let (source, binding, repository) = repository_fixture(temp_dir.path(), "timeout-repository"); + let workspace_root = temp_dir.path().join("workspaces"); + let manager = WorkspaceManager::new(manager_config( + &workspace_root, + HookConfig::default(), + CleanupConfig::default(), + )) + .expect("manager should build") + .with_repository_checkouts(BTreeMap::from([( + binding.repository_id().to_string(), + repository, + )])); + let mut child = sample_issue("COE-TIMEOUT-CHILD"); + child.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding.clone())); + let child = manager.ensure(&child).await.expect("child should exist"); + + let fsmonitor = temp_dir.path().join("capture-parent-probe-env.sh"); + let marker = temp_dir.path().join("parent-probe-fsmonitor-ran.txt"); + std::fs::write( + &fsmonitor, + format!("#!/bin/sh\nprintf invoked > {}\n", shell_quote(&marker)), + ) + .expect("fsmonitor hook should be written"); + std::fs::set_permissions(&fsmonitor, std::fs::Permissions::from_mode(0o755)) + .expect("fsmonitor hook should be executable"); + git( + child.handle.workspace_path(), + &[ + "config", + "core.fsmonitor", + fsmonitor.to_str().expect("fsmonitor path"), + ], + ); + + let mut parent = sample_issue("COE-TIMEOUT-PARENT"); + parent.issue_id = "timeout-parent-id".to_owned(); + let error = manager + .prepare_parent_execution_root( + &parent, + 1, + vec![ParentCheckoutRequest { + issue_id: child.handle.issue_id().to_owned(), + repository_id: binding.repository_id().to_string(), + checkout_generation: child + .handle + .checkout_generation() + .expect("generation") + .to_owned(), + lease_owner: "ancestor-integration:timeout-parent-id".to_owned(), + required_merge_commits: vec![git(&source, &["rev-parse", "HEAD"])], + }], + ) + .await + .expect_err("child-controlled fsmonitor must be rejected"); + + assert!( + error.to_string().contains("core.fsmonitor"), + "unexpected process-configuration error: {error}" + ); + assert!( + !marker.exists(), + "fsmonitor must not run during parent probes" + ); + assert!(!parent_generation_path(&manager, &parent, 1).exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn parent_preparation_rejects_worktree_conditional_fsmonitor_before_execution() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().expect("temp dir should exist"); + let (source, binding, repository) = + repository_fixture(temp_dir.path(), "conditional-fsmonitor-repository"); + let workspace_root = temp_dir.path().join("workspaces"); + let manager = WorkspaceManager::new(manager_config( + &workspace_root, + HookConfig::default(), + CleanupConfig::default(), + )) + .expect("manager should build") + .with_repository_checkouts(BTreeMap::from([( + binding.repository_id().to_string(), + repository, + )])); + let mut child = sample_issue("COE-CONDITIONAL-FSMONITOR-CHILD"); + child.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding.clone())); + let child = manager.ensure(&child).await.expect("child should exist"); + + let fsmonitor = temp_dir.path().join("conditional-fsmonitor.sh"); + let marker = temp_dir.path().join("conditional-fsmonitor-ran.txt"); + std::fs::write( + &fsmonitor, + format!("#!/bin/sh\nprintf invoked > {}\n", shell_quote(&marker)), + ) + .expect("fsmonitor hook should be written"); + std::fs::set_permissions(&fsmonitor, std::fs::Permissions::from_mode(0o755)) + .expect("fsmonitor hook should be executable"); + let included_config = temp_dir.path().join("worktree-conditional.gitconfig"); + std::fs::write( + &included_config, + format!( + "[core]\n\tfsmonitor = {}\n", + fsmonitor.to_str().expect("fsmonitor path") + ), + ) + .expect("conditional config should be written"); + git( + child.handle.workspace_path(), + &[ + "config", + "--local", + "includeIf.gitdir:**/worktrees/*.path", + included_config.to_str().expect("conditional config path"), + ], + ); + + let mut parent = sample_issue("COE-CONDITIONAL-FSMONITOR-PARENT"); + parent.issue_id = "conditional-fsmonitor-parent-id".to_owned(); + let error = manager + .prepare_parent_execution_root( + &parent, + 1, + vec![ParentCheckoutRequest { + issue_id: child.handle.issue_id().to_owned(), + repository_id: binding.repository_id().to_string(), + checkout_generation: child + .handle + .checkout_generation() + .expect("generation") + .to_owned(), + lease_owner: "ancestor-integration:conditional-fsmonitor-parent-id".to_owned(), + required_merge_commits: vec![git(&source, &["rev-parse", "HEAD"])], + }], + ) + .await + .expect_err("worktree-conditional fsmonitor must be rejected"); + + assert!( + error.to_string().contains("includeif.gitdir"), + "unexpected conditional-configuration error: {error}" + ); + assert!( + !marker.exists(), + "worktree-conditional fsmonitor must not run during parent preparation" + ); + assert!(!parent_generation_path(&manager, &parent, 1).exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn parent_execution_root_rejects_a_symlinked_parent_directory_before_git() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let workspace_root = temp_dir.path().join("workspaces"); + let outside = temp_dir.path().join("outside"); + std::fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + std::fs::create_dir_all(&outside).expect("outside should exist"); + symlink(&outside, workspace_root.join("parents")) + .expect("parent directory symlink should be created"); + let manager = WorkspaceManager::new(manager_config( + &workspace_root, + HookConfig::default(), + CleanupConfig::default(), + )) + .expect("manager should build"); + + assert!(matches!( + manager + .prepare_parent_execution_root( + &sample_issue("COE-PARENT-SYMLINK"), + 1, + vec![ParentCheckoutRequest { + issue_id: "child-id".to_owned(), + repository_id: "local:repository:child".to_owned(), + checkout_generation: "generation-1".to_owned(), + lease_owner: "ancestor-integration:parent-id".to_owned(), + required_merge_commits: Vec::new(), + }], + ) + .await, + Err(WorkspaceError::InstructionPathEscape { .. }) + )); + assert!( + std::fs::read_dir(&outside) + .expect("outside should remain readable") + .next() + .is_none() + ); +} + #[cfg(unix)] fn current_dir_command(output_path: &str) -> String { format!("pwd > {output_path}") @@ -82,6 +1226,26 @@ fn current_dir_command(output_path: &str) -> String { format!("cd > {output_path}") } +#[cfg(unix)] +fn parent_after_create_once_command() -> &'static str { + "if [ -e parent-after-create.txt ]; then exit 41; fi; echo created > parent-after-create.txt" +} + +#[cfg(windows)] +fn parent_after_create_once_command() -> &'static str { + "if exist parent-after-create.txt (exit /b 41) else (echo created> parent-after-create.txt)" +} + +#[cfg(unix)] +fn failing_parent_after_create_command() -> &'static str { + "exit 23" +} + +#[cfg(windows)] +fn failing_parent_after_create_command() -> &'static str { + "exit /b 23" +} + #[cfg(unix)] fn timeout_command() -> &'static str { "sleep 1" diff --git a/docs/architecture.md b/docs/architecture.md index c67ce2753..6aa9c65ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,20 @@ after terminal orchestrator outcomes and retained checkout-generation lease resources are present; scope changes after freeze are recorded as `HierarchyChanged` and cannot silently widen the parent run. +An eligible parent is materialized as a repository-neutral execution root for +the frozen hierarchy generation. The workspace backend resolves only active +ancestor leases, groups their retained generations by canonical repository ID, +and asks the workspace manager for one contained integration worktree per +repository. Those worktrees share a selected child's Git object store, refresh +the configured target through that repository's credential provider, and pin +the provider merge-result commits used for admission. An orchestrator-owned +copy of the checkout map remains outside the parent runtime root, and every +reopen compares the runtime map to that copy before rechecking target and merge +ancestry. The parent worker starts +once with this root as `cwd`, a relative checkout-handle map, and a generic +`parent_multi_checkout` envelope; neither the directory layout nor the prompt +assigns repository roles. + ### 3.2 OpenHands is the execution adapter OpenHands provides: diff --git a/docs/codex-app-server-harness.md b/docs/codex-app-server-harness.md index f7e050f0b..2bca1732b 100644 --- a/docs/codex-app-server-harness.md +++ b/docs/codex-app-server-harness.md @@ -92,6 +92,13 @@ input plus the maximum-permission turn profile: } ``` +Parent threads use the repository-neutral parent root as that working +directory. Their conversation manifest binds the same relative checkout map and +`parent_multi_checkout` scope used by OpenHands, while recording effective +`trusted_host` containment for the local danger-full-access profile. Codex does +not receive a synthetic leaf checkout envelope or a parent memory grant owned +by the later parent-controller lifecycle. + `approvalPolicy: "never"` means OpenSymphony does not wait for human approval callbacks from Codex. Execution failures are streamed back through Codex and handled by the model loop. `dangerFullAccess` intentionally carries no diff --git a/docs/configuration.md b/docs/configuration.md index e8035035d..2f0740200 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -133,6 +133,13 @@ envelope, and refuse attach when binding or provenance is incompatible. home configuration. `memory init` refuses to treat a selected central config as a repository-local memory file; initialize the local memory config instead. +When a project set supplies hash-pinned integration instructions, the resolved +artifact is carried into parent worker construction. Parent launch rereads the +artifact and verifies its configured hash before composing the prompt. The +artifact may describe topology-specific commands, but repository instructions +remain separate sections keyed by canonical repository ID and the runtime does +not convert those instructions into repository roles. + Every run records one `sha256:` config generation in startup diagnostics and the initial control-plane event. Resolved credential values are never part of the central model or its serialized diagnostics. diff --git a/docs/openhands-agent-server.md b/docs/openhands-agent-server.md index c48386840..332045aaa 100644 --- a/docs/openhands-agent-server.md +++ b/docs/openhands-agent-server.md @@ -96,6 +96,15 @@ truthful trusted-host process-`cwd` containment receipt. A compatible persisted conversation may be reused only when its envelope matches; a mismatch is rejected before attach. +For an eligible parent, `workspace.working_dir` is the non-Git parent execution +root. The conversation manifest binds the parent envelope to the OpenHands +conversation ID and records the complete relative checkout map with requested +`parent_multi_checkout` scope. Trusted local OpenHands runs report +`trusted_host`; the prompt and `cwd` do not claim filesystem sandboxing. The +full first prompt combines the generic parent lifecycle, optional verified +project-set integration instructions, parent task facts, the checkout map, and +repository instructions keyed by canonical repository ID. + When the local memory server issues a process-scoped worker grant, the grant is also part of conversation compatibility. The agent-server create contract does not provide a conversation MCP-config update operation, so a persisted diff --git a/docs/operations.md b/docs/operations.md index 91e2f59ac..ea3f32951 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -707,6 +707,29 @@ manually reset a quarantined checkout into service. The runtime envelope also records that current local containment is process `cwd` containment on a trusted host, not a sandbox boundary. +Parent runs publish a separate generation-scoped root below +`workspace.root/parents/`. Inspect `parent-manifest.json`, +`child-checkouts.json`, and `.opensymphony/parent-runtime.json` together when a +parent cannot launch. The workspace manager also keeps the authoritative copy +of each generation's checkout map below +`workspace.root/.opensymphony-parent-pins/`; a mismatch means the runtime copy +was modified and must not be repaired in place. Preparation blocks before a repository operation when a +required child subtree lacks an active ancestor lease, a retained generation is +stale or dirty, merge evidence is ambiguous across repositories, a remote no +longer matches policy, or a contained worktree fails its shared-storage and +path checks. Parent preparation also rejects retained-checkout local/worktree +HTTP, credential, transport, SSH-command, protocol, and URL-rewrite settings +before an authenticated fetch; remove or reconcile those settings before +retrying. Checkout-controlled process filters are rejected before integration +worktree creation, while retained hooks and host-level Git configuration are +disabled for that operation. Do not repair generated parent files or substitute +a filesystem path for the recorded opaque handle; reconcile the hierarchy/lease +evidence or retained checkout and rematerialize the generation. A configured +`after_create` hook is required for parent roots too; its failure rolls back the +incomplete root, a hook-created root-level Git repository also fails and rolls +back, and reuse requires the hook completion receipt. Parent paths include a +stable issue-identity digest after the sanitized identifier. + Strict `opensymphony rehydrate` also derives the desired repository, harness, model, and generation envelope from the current central routing inventory before creating a replacement conversation. If that envelope differs from the diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index bbadcffe6..eaf063dbb 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -502,6 +502,31 @@ prove atomic staged publication, target-branch and non-shallow verification, clean-worktree enforcement, wrong-remote quarantine, collision-resistant checkout keys, instruction hashing, and repository-only prompt composition. +The parent execution-root fixture uses three temporary remotes, multiple +children in one repository, an actual squash merge, an actual rebase and +fast-forward merge, and a recorded shallow generation. It proves one non-Git +root, one shared-object worktree per canonical repository, exact target and +provider merge-result reachability, unchanged child HEAD/status/manifest bytes, +and rejection of stale generations, missing ancestor leases, ambiguous merge +evidence, dirty children, wrong remotes, arbitrary handles, changed root +requests, runtime-map target repinning, and symlink escapes. It also proves +checkout-local HTTP transport configuration is rejected before credential +exposure; checkout-controlled process filters, fsmonitor, custom hooks paths, +and conditional includes are rejected before parent verification; retained +`post-checkout` hooks do not run during worktree creation; colliding sanitized +parent identifiers remain distinct; and `after_create` cannot turn a parent +root into a Git repository. It also covers parent `after_create` +receipt/reuse/failure behavior and proves a child-controlled direct or +worktree-conditional fsmonitor executable is rejected without running before +the incomplete parent root is rolled back. A lower-level regression proves the +orchestrator Git command overrides checkout-controlled fsmonitor configuration +and default repository hooks at execution time. Focused +OpenHands and Codex tests bind the same logical `parent_multi_checkout` envelope +while preserving truthful `trusted_host` containment and omitting a leaf runtime +envelope. +The parent launch tests re-read project-set integration instructions after the +lifecycle hook boundary and reject changed bytes before harness attachment. + ### Scenario B: conversation reuse - run the same issue a second time against the same workspace diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index c932e0dc0..090048bec 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -122,6 +122,95 @@ scope and leases and requests a full tracker observation on the next tick. Generation-checked replans also fetch full reachability before reconciling changed edges, so rejecting a stale replan cannot discard reparented evidence. +### Parent execution roots + +A frozen parent hierarchy uses a separate non-Git root: + +```text +/parents/parent--// + parent-manifest.json + child-checkouts.json + integration-plan.md + evidence/ + repositories// + +/.opensymphony-parent-pins/ + parent-/.json +``` + +`child-checkouts.json` is the durable handle boundary. It groups every leased +child generation by canonical repository, records the safe remote fingerprint, +target and provider merge-result commits, instruction provenance, and the +selected shared-storage generation. Reopening an existing root verifies that +the incoming generation, lease owners, children, and merge results still match +that map, then revalidates every retained child generation before use. A parent +whose children are all canceled still gets a valid root with an empty checkout +map. Arbitrary paths and handles that are absent from the map are rejected. +The workspace manager publishes an exact orchestrator-owned copy outside the +parent runtime root and requires the runtime-visible map to match it on every +reopen. A parent turn therefore cannot authorize an older integration target by +rewriting its local map. Reopen also repeats provider merge-result reachability +checks against the pinned target. + +Parent roots follow the same `after_create` contract as leaf workspaces. A new +empty root runs the configured hook before metadata bootstrap, writes the +root-scoped completion receipt, and publishes repository artifacts only after +the hook succeeds. The manager rechecks that the hook did not initialize a +root-level Git repository before publication. Hook failure or invariant drift +removes the incomplete generation. Reuse requires the matching receipt and does +not rerun the hook. Parent workspace keys include an issue-identity digest so +identifiers that sanitize to the same display value remain distinct. + +Parent preparation verifies retained child identity and cleanliness without +moving a child branch or HEAD. It can accept a recorded shallow generation, +then fetch and deepen the configured target through the repository credential +provider before adding a detached integration worktree. A shallow-to-complete +storage transition remains valid against the immutable child manifest on later +parent generations and ordinary child retry/reuse. Preparation gives every +retained child generation an independent verification deadline. It also applies +one deadline to integration worktree creation, instruction loading, and every +final Git verification probe. Timed-out Git process groups are terminated and +incomplete roots are rolled back so the same hierarchy generation can retry. +Credentialed fetches use the configured repository remote directly, reject +checkout-local and worktree HTTP, credential, transport, SSH-command, protocol, +and URL-rewrite settings before exposing the configured secret, disable +repository-local credential helpers, and route Git hooks to a fresh empty +directory. Git receives credentials only through the orchestrator-owned askpass +process. Timed-out authenticated Git process trees are terminated and awaited +before askpass state is removed on every supported platform. Integration +worktree creation uses an empty hooks directory and isolated system/global Git +configuration, and rejects retained-checkout process-filter, fsmonitor, custom +hooks-path, and conditional-include configuration before any parent verification +probe. Every workspace-manager Git probe also supplies a version-compatible +empty `core.fsmonitor` value and a fresh empty `core.hooksPath` on the command +itself so mutable included configuration and default repository hooks cannot +activate an executable between validation and use. The new worktree must +remain below `repositories/`, share the selected child's Git common directory, +match both the provider identity and locator fingerprints for fetch and push +remotes, be clean, and point at the recorded target commit. Several children in +one repository share one handle; +the target must contain every provider merge-result commit, while replaced +feature commits from squash or rebase are not required. + +The initial launch requires the integration worktrees to match their prepared +targets exactly. A continuation or failure retry may reattach when the recorded +target remains an ancestor of the current integration HEAD, preserving +parent-owned committed and uncommitted work. Repository instruction bytes share +one aggregate size budget across the entire parent prompt. Every launch reloads +the pinned repository instructions so any path that creates a fresh conversation +has a complete prompt; reused conversations consume continuation guidance. The +runtime revalidates the parent root, envelope, and instructions after the +`before_run` hook and before harness attachment. `WORKFLOW.md` instruction hashes +and prompt content both exclude its front matter. Project-set integration +instructions are also re-read and hash-checked after `before_run`; the parent +prompt is composed only from those final verified bytes. + +The parent runtime artifact records the complete relative checkout map, +requested `parent_multi_checkout` scope, harness/model selection, and truthful +`trusted_host` or `workspace_confined` containment. Parent memory grants remain +owned by the later parent-controller lifecycle and are not synthesized from a +leaf checkout grant. + ## 4. Workspace directory layout Recommended layout inside each issue workspace: From 9ade55ba1714a6fd3948824f70b9bb0fa0cdec58 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 12 Sep 2026 07:27:42 -0500 Subject: [PATCH 03/29] docs(memory): capture COE-553 and sync project docs Record the merged parent-root implementation in project memory and sync managed topic sections from the complete existing capture corpus. Preserve prior issue history while adding the new harness topic. Validation: memory lint --public-docs, zero pending captures or warnings, and git diff --check. No runtime code changed. --- .opensymphony/memory/memory.yaml | 156 +++++++++++++++++++++++++++++++ docs/accessibility.md | 2 + docs/build.md | 2 + docs/cli.md | 2 + docs/code-intelligence.md | 2 + docs/code-review.md | 2 + docs/codex-runtime.md | 2 + docs/codex.md | 2 + docs/configuration.md | 2 + docs/control.md | 2 + docs/data-contracts.md | 4 +- docs/debugging.md | 2 + docs/desktop.md | 2 + docs/developer-experience.md | 2 + docs/docs.md | 2 + docs/documentation.md | 2 + docs/duckdb.md | 2 + docs/evidence.md | 4 +- docs/experiments.md | 3 + docs/frontend.md | 2 + docs/gateway.md | 2 + docs/graph-view.md | 2 + docs/harness-runtime.md | 2 + docs/harness.md | 37 ++++++++ docs/installer.md | 2 + docs/linear.md | 2 + docs/mcp.md | 2 + docs/memory.md | 2 + docs/migration.md | 2 + docs/model-configuration.md | 2 + docs/okf.md | 2 + docs/openhands-runtime.md | 2 + docs/operations.md | 2 + docs/orchestrator.md | 2 + docs/outcomes.md | 3 + docs/planning.md | 2 + docs/process-protocol.md | 3 + docs/provenance.md | 3 + docs/quality.md | 4 +- docs/query-packs.md | 2 + docs/release.md | 2 + docs/repository-layout.md | 2 + docs/run-detail.md | 2 + docs/runner.md | 3 + docs/runtime.md | 2 + docs/rust.md | 2 + docs/scheduler.md | 3 + docs/security.md | 2 + docs/target-repo-bootstrap.md | 2 + docs/task-packets.md | 3 + docs/template.md | 2 + docs/testing.md | 2 + docs/tests.md | 2 + docs/threejs.md | 2 + docs/tui.md | 2 + docs/ui.md | 2 + docs/verifiers.md | 3 + docs/web.md | 2 + docs/workflow.md | 2 + docs/workspace-lifecycle.md | 2 + docs/workspace.md | 3 + 61 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 docs/harness.md diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index 96e98d289..86395e30a 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -44,6 +44,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -83,6 +84,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' ai-pr-review-human-setup: @@ -163,6 +165,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -214,6 +217,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -250,6 +254,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -301,6 +306,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -331,6 +337,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -370,6 +377,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' code-review: @@ -399,6 +407,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -439,6 +448,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' codex: @@ -472,6 +482,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -523,6 +534,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -575,6 +587,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -616,6 +629,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' configuration: @@ -646,6 +660,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -691,6 +706,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M7: Shared Client And Desktop Alpha' @@ -728,6 +744,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -779,6 +796,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -796,6 +814,8 @@ areas: - area:data-contracts - area:evidence - area:experiments + - area:harness + - area:orchestrator - area:outcomes - area:process-protocol - area:provenance @@ -805,8 +825,10 @@ areas: - area:task-packets - area:verifiers - area:workspace + - area:workspace-lifecycle linear_milestones: - 'M0: Foundation And Evidence Contracts' + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' debugging: title: Debugging @@ -834,6 +856,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -873,6 +896,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' deployment-modes: @@ -921,6 +945,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -972,6 +997,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1007,6 +1033,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -1057,6 +1084,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1130,6 +1158,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -1181,6 +1210,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1210,6 +1240,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -1250,6 +1281,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' duckdb: @@ -1283,6 +1315,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -1334,6 +1367,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1360,6 +1394,8 @@ areas: linear_labels: - area:evidence - area:experiments + - area:harness + - area:orchestrator - area:outcomes - area:process-protocol - area:provenance @@ -1369,8 +1405,10 @@ areas: - area:task-packets - area:verifiers - area:workspace + - area:workspace-lifecycle linear_milestones: - 'M0: Foundation And Evidence Contracts' + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' experiments: title: Experiments @@ -1383,8 +1421,12 @@ areas: source_refs: linear_labels: - area:experiments + - area:harness + - area:orchestrator - area:scheduler + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' frontend: title: Frontend @@ -1412,6 +1454,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -1451,6 +1494,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' gateway: @@ -1484,6 +1528,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -1535,6 +1580,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1565,6 +1611,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -1604,8 +1651,24 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' + harness: + title: Harness + docs_target: docs/harness.md + visibility: public + status: stable + confidence: 95 + aliases: + - harness + source_refs: + linear_labels: + - area:harness + - area:orchestrator + - area:workspace-lifecycle + linear_milestones: + - 'M12.96: Parent Integration Lifecycle' harness-runtime: title: Harness Runtime docs_target: docs/harness-runtime.md @@ -1638,6 +1701,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -1688,6 +1752,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1800,6 +1865,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -1850,6 +1916,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1884,6 +1951,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -1935,6 +2003,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1984,6 +2053,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -2034,6 +2104,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2074,6 +2145,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -2125,6 +2197,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M6.5: Memory Server' @@ -2159,6 +2232,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -2198,6 +2272,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' migration-1-0-0: @@ -2243,6 +2318,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -2293,6 +2369,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2327,6 +2404,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -2377,6 +2455,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2435,6 +2514,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -2475,6 +2555,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' operations: @@ -2510,6 +2591,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -2562,6 +2644,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M6.5: Memory Server' @@ -2601,6 +2684,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -2652,6 +2736,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2665,9 +2750,13 @@ areas: - outcomes source_refs: linear_labels: + - area:harness + - area:orchestrator - area:outcomes - area:verifiers + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' planning: title: Planning @@ -2695,6 +2784,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -2734,6 +2824,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' prerequisites: @@ -2764,9 +2855,13 @@ areas: - process-protocol source_refs: linear_labels: + - area:harness + - area:orchestrator - area:process-protocol - area:runner + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' provenance: title: Provenance @@ -2778,9 +2873,13 @@ areas: - provenance source_refs: linear_labels: + - area:harness + - area:orchestrator - area:provenance - area:task-packets + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' quality: title: Quality @@ -2794,6 +2893,8 @@ areas: linear_labels: - area:evidence - area:experiments + - area:harness + - area:orchestrator - area:outcomes - area:process-protocol - area:provenance @@ -2803,8 +2904,10 @@ areas: - area:task-packets - area:verifiers - area:workspace + - area:workspace-lifecycle linear_milestones: - 'M0: Foundation And Evidence Contracts' + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' query-packs: title: Query Packs @@ -2833,6 +2936,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -2872,6 +2976,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' release: @@ -2900,6 +3005,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -2940,6 +3046,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' repository-layout: @@ -2959,6 +3066,7 @@ areas: linear_milestones: - 'M10.3: Codex And Subscription Readiness' - 'M12.6: Tree-sitter Code Intelligence' + - 'M12.96: Parent Integration Lifecycle' - 'M9.5: Developer Build Acceleration' run-detail: title: Run Detail @@ -2987,6 +3095,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -3026,6 +3135,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' runner: @@ -3038,8 +3148,12 @@ areas: - runner source_refs: linear_labels: + - area:harness + - area:orchestrator - area:runner + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' runtime: title: Runtime @@ -3072,6 +3186,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -3123,6 +3238,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3152,6 +3268,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -3192,6 +3309,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' scheduler: @@ -3204,8 +3322,12 @@ areas: - scheduler source_refs: linear_labels: + - area:harness + - area:orchestrator - area:scheduler + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' security: title: Security @@ -3238,6 +3360,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -3289,6 +3412,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3353,6 +3477,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -3392,6 +3517,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' task-packets: @@ -3405,9 +3531,13 @@ areas: - task-packets source_refs: linear_labels: + - area:harness + - area:orchestrator - area:task-packets - area:workspace + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' template: title: Template @@ -3435,6 +3565,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -3474,6 +3605,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' testing: @@ -3507,6 +3639,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -3558,6 +3691,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3618,6 +3752,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -3658,6 +3793,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' threejs: @@ -3686,6 +3822,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -3725,6 +3862,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' tui: @@ -3758,6 +3896,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -3808,6 +3947,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3842,6 +3982,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:harness-runtime - area:installer - area:linear @@ -3892,6 +4033,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3920,8 +4062,12 @@ areas: - verifiers source_refs: linear_labels: + - area:harness + - area:orchestrator - area:verifiers + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' web: title: Web @@ -3949,6 +4095,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -3988,6 +4135,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' websocket-runtime: @@ -4031,6 +4179,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -4071,6 +4220,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' workspace: @@ -4083,8 +4233,12 @@ areas: - workspace source_refs: linear_labels: + - area:harness + - area:orchestrator - area:workspace + - area:workspace-lifecycle linear_milestones: + - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' workspace-and-lifecycle: title: Workspace and Lifecycle @@ -4127,6 +4281,7 @@ areas: - area:frontend - area:gateway - area:graph-view + - area:harness - area:installer - area:linear - area:mcp @@ -4166,6 +4321,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' redaction: diff --git a/docs/accessibility.md b/docs/accessibility.md index 96a3f5036..e5c7fca45 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/build.md b/docs/build.md index 2d5e3f170..3f1bf4953 100644 --- a/docs/build.md +++ b/docs/build.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/cli.md b/docs/cli.md index e7b8aa5a4..cd40d453c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index 58a29a623..f82de76a3 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -273,6 +273,7 @@ references, and never returns source snippets. - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -328,6 +329,7 @@ references, and never returns source snippets. - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-review.md b/docs/code-review.md index c38b67dcb..bcdf2ab0a 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index 6443f2313..9376bf960 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex.md b/docs/codex.md index 6c0b2fe9d..e01201c1f 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -119,6 +119,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -214,6 +215,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/configuration.md b/docs/configuration.md index 2f0740200..e6e3a9cb0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1069,6 +1069,7 @@ copy that guide into the target repository. - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1222,6 +1223,7 @@ copy that guide into the target repository. - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/control.md b/docs/control.md index e22a3e763..6ac8b0248 100644 --- a/docs/control.md +++ b/docs/control.md @@ -119,6 +119,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -214,6 +215,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index b483d7cb2..5eb270db9 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -11,12 +11,12 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. - COE-566 contributed: No merged PR source was matched during capture. -- COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -33,6 +33,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -42,6 +43,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 ## Source refs +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/debugging.md b/docs/debugging.md index 5e4c84f28..55ef6473a 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -76,6 +76,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -128,6 +129,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/desktop.md b/docs/desktop.md index 6ddd80446..b42b72747 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index 53557c0f8..a8b4ba9d5 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/docs.md b/docs/docs.md index 7f61838ec..3d4c11f02 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -217,6 +218,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/documentation.md b/docs/documentation.md index ddd513b30..0cb87163f 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/duckdb.md b/docs/duckdb.md index 369066376..3eb8d36fd 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/evidence.md b/docs/evidence.md index 4a09e5fc0..8659f169a 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -11,12 +11,12 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. - COE-566 contributed: No merged PR source was matched during capture. -- COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -33,6 +33,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -42,6 +43,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 ## Source refs +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/experiments.md b/docs/experiments.md index c6df4da14..8ed5aee87 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-566: Implement configurable run matrices and scheduling ## Source refs +- COE-553 - COE-566 diff --git a/docs/frontend.md b/docs/frontend.md index 0012b6b9c..2533fe5f2 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/gateway.md b/docs/gateway.md index 570185635..bf1009758 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -141,6 +141,7 @@ baseline, not an issue-workspace overlay. - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -238,6 +239,7 @@ baseline, not an issue-workspace overlay. - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/graph-view.md b/docs/graph-view.md index fed73dc36..ad9fab0cb 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index 461ae9ba0..c14bf81a8 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness.md b/docs/harness.md new file mode 100644 index 000000000..6eaf82573 --- /dev/null +++ b/docs/harness.md @@ -0,0 +1,37 @@ +--- +type: topic-doc +area: harness +visibility: public +last_memory_sync: 2026-09-12T12:25:39.170247+00:00 +--- + +# Harness + + + +## Current model + +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) + +## Important invariants + +- Preserve the behavior described in the recent captured changes unless current code and tests show it has changed. +- Use capsule source refs to inspect the original PR or Linear issue when context is ambiguous. + +## Operational flow + +- No generated diagram requested for this sync. + +## Known gotchas + +- No area-specific gotchas were inferred from the selected memory. + +## Recent changes + +- COE-553: Parent Execution Roots And Child Workspace Reuse + +## Source refs + +- COE-553 + + diff --git a/docs/installer.md b/docs/installer.md index e80c6825c..d1ad92939 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/linear.md b/docs/linear.md index 11d64aa27..20777688d 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/mcp.md b/docs/mcp.md index 542e9b49b..c58c7deaf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/memory.md b/docs/memory.md index cbac1597b..0b581882a 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -783,6 +783,7 @@ are covered by the same archive operation. - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -944,6 +945,7 @@ are covered by the same archive operation. - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/migration.md b/docs/migration.md index dcbee8734..8c6c57cfb 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -76,6 +76,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -128,6 +129,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index 7ef12e9ea..de161fec4 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/okf.md b/docs/okf.md index 3119d3c5a..2087c9c30 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index eaed436e3..ca1ec2979 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/operations.md b/docs/operations.md index ea3f32951..2ebcee5af 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -969,6 +969,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1133,6 +1134,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index a13d0822d..e2a4aa979 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -119,6 +119,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -214,6 +215,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/outcomes.md b/docs/outcomes.md index a2d4d6178..112f1060f 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-564: Implement verifier execution and outcome records ## Source refs +- COE-553 - COE-564 diff --git a/docs/planning.md b/docs/planning.md index 30f71a1af..7800906fb 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index 18e866240..c7216fb6d 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs +- COE-553 - COE-567 diff --git a/docs/provenance.md b/docs/provenance.md index 2a6600f66..60bc3daff 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-563 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-563: Implement task-packet admission and freeze tooling ## Source refs +- COE-553 - COE-563 diff --git a/docs/quality.md b/docs/quality.md index 6a5b01da3..113b774e3 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -11,12 +11,12 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. - COE-566 contributed: No merged PR source was matched during capture. -- COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -33,6 +33,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -42,6 +43,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 ## Source refs +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/query-packs.md b/docs/query-packs.md index 9cb2382c0..83c590482 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/release.md b/docs/release.md index 67b33b2a4..dde15bfe6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 9910d6bf1..44f849b4d 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -246,6 +246,7 @@ of dead bridge code. - COE-506: Invert CodeIntelIndex trait ownership after AST memory integration - COE-507: Deduplicate query-pack assets for grammar variants - COE-508: Cache code-intel parsers and compiled query packs +- COE-553: Parent Execution Roots And Child Workspace Reuse ## Source refs @@ -307,5 +308,6 @@ of dead bridge code. - COE-506 - COE-507 - COE-508 +- COE-553 diff --git a/docs/run-detail.md b/docs/run-detail.md index dbd65fb6b..fabc561b9 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/runner.md b/docs/runner.md index df7b1c18d..5e70798df 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs +- COE-553 - COE-567 diff --git a/docs/runtime.md b/docs/runtime.md index 3bfa2820a..4e40bb3ee 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -119,6 +119,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -214,6 +215,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/rust.md b/docs/rust.md index 9e7e5b778..870f15194 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/scheduler.md b/docs/scheduler.md index ebde73665..a9654d64c 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-566: Implement configurable run matrices and scheduling ## Source refs +- COE-553 - COE-566 diff --git a/docs/security.md b/docs/security.md index c9c6b7e6f..2c4f5a6bc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index 87b684759..953dede2f 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -76,6 +76,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -128,6 +129,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/task-packets.md b/docs/task-packets.md index dc9489abc..0a662b99f 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-563 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. @@ -29,11 +30,13 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-563: Implement task-packet admission and freeze tooling - COE-565: Implement isolated workspace materialization ## Source refs +- COE-553 - COE-563 - COE-565 diff --git a/docs/template.md b/docs/template.md index 70c305773..7a94de1e2 100644 --- a/docs/template.md +++ b/docs/template.md @@ -76,6 +76,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -128,6 +129,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/testing.md b/docs/testing.md index 4d3758907..62818b55c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -214,6 +215,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/tests.md b/docs/tests.md index 68a305ffc..748222d3c 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/threejs.md b/docs/threejs.md index 9a2f351ba..0cf9c8d89 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/tui.md b/docs/tui.md index ada69a189..9a4fb4d31 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -206,6 +207,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/ui.md b/docs/ui.md index f3dc3891c..fcdcc22b5 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -115,6 +115,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/verifiers.md b/docs/verifiers.md index 493b6b849..6bd875c95 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-564: Implement verifier execution and outcome records ## Source refs +- COE-553 - COE-564 diff --git a/docs/web.md b/docs/web.md index e108c4071..bcac0fb98 100644 --- a/docs/web.md +++ b/docs/web.md @@ -76,6 +76,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -128,6 +129,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/workflow.md b/docs/workflow.md index 5d0c32d9a..054a48362 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index b0eb964cf..551893881 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -76,6 +76,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -128,6 +129,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-549 - COE-550 - COE-551 +- COE-553 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace.md b/docs/workspace.md index dac12d35a..755ff55d4 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -11,6 +11,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 ## Current model +- COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-565 contributed: No merged PR source was matched during capture. ## Important invariants @@ -28,10 +29,12 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 ## Recent changes +- COE-553: Parent Execution Roots And Child Workspace Reuse - COE-565: Implement isolated workspace materialization ## Source refs +- COE-553 - COE-565 From b4e1b679c8079e80fd05d376e058756c751a2c09 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 12 Sep 2026 15:41:44 -0500 Subject: [PATCH 04/29] feat: add restart-safe parent integration controller (#237) * feat(orchestrator): add restart-safe parent integration * fix(orchestrator): harden parent retry lifecycle * fix(orchestrator): trust runtime parent evidence * fix(orchestrator): close parent recovery gaps Summary: - trust only runtime-owned command, root, deadline, and cleanup evidence - gate terminal release effects on durable parent-controller completion - preserve one OpenHands conversation by restoring its scoped memory bearer - reconcile prelaunch and lost-outcome crash windows without duplicate work - validate canonical repository identities and checkout ancestry for memory Rationale: - worker-authored receipts cannot prove command execution or cleanup - restart recovery must retain leases and authorization across every gap Tests: - cargo test-system-duckdb --lib - cargo test-system-duckdb --test scheduler - cargo test-system-duckdb --test memory - cargo test-system-duckdb --test workspace_manager - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(orchestrator): complete parent retry recovery * fix(orchestrator): secure parent completion recovery * fix(orchestrator): close durable parent scope gaps Summary: - bound worker memory rows to the parent and authorized descendants - persist command events and recover legacy controllers before reattachment - keep dry-run and empty-target parent lifecycles repository neutral - document and test the restart-safe recovery behavior Rationale: - restart recovery must retain one authoritative controller and conversation - parent capture and memory reads must not inherit unrelated leaf scope Tests: - cargo test-system-duckdb --lib (1330 passed) - cargo test-system-duckdb --test scheduler (110 passed) - cargo test-system-duckdb --test memory (46 passed) - cargo test-system-duckdb --test workspace_manager (42 passed) - cargo test-system-duckdb --test fake_server_contract (14 passed) - cargo test-system-duckdb --test issue_session_runner (19 passed) - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(memory): keep parent capture repository neutral Summary: - carry the durable parent-integration marker into capture planning - clear default repository ownership for parent capture routing - verify the persisted empty-target parent row has no repository scope Rationale: - repository-neutral parents must not inherit a leaf repository from project or catalog defaults during automatic capture Tests: - cargo test-system-duckdb --lib - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(orchestrator): close parent terminal review gaps * fix(orchestrator): fence parent tracker confirmation Summary: - park verified parent attempts until terminal tracker confirmation, including recovery from persisted state - reject completed controllers from stale hierarchy generations and clear their terminal-success marker - retain run provenance for repository-neutral parent captures Rationale: - a successful integration attempt must not relaunch or release hierarchy state before current tracker and hierarchy proof agree - empty-target parents still need durable run and attempt attribution Tests: - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --lib - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --test scheduler - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --test memory - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --test workspace_manager - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --test fake_server_contract - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --test issue_session_runner - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(orchestrator): retain capturable parent evidence Summary: - discover generation-bound parent run envelopes and retry terminal capture after daemon restart - retain completed parent roots until the cleanup milestone can persist a capture acknowledgement while still releasing finalized leases - require every work-item scope and preserve passed parent outcomes at zero retry budget Rationale: - terminal capture must survive reconciliation, process restart, and aggregate memory authorization without trusting partial scope or deleting evidence Tests: - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb - GIT_CONFIG_GLOBAL=/dev/null cargo check-system-duckdb - GIT_CONFIG_GLOBAL=/dev/null cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(orchestrator): close final parent recovery gaps Summary: - retain recovered workers across failed controller persistence and reopen completed parents when an accepted hierarchy change invalidates their run - bind final verification to exact transient command hashes, reject stale attempt events, and retain parent evidence leases through restart - document the OSYM-893 cleanup handoff and add lifecycle regressions Rationale: - prevent untracked harness work, stale command evidence, and completed-run fences from violating restart-safe parent integration - keep registered integration worktrees valid until durable capture cleanup can release their protective leases in order Tests: - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check --- crates/opensymphony-cli/src/memory.rs | 1148 ++++++++- .../src/orchestrator_run/backends.rs | 1056 +++++++- .../src/orchestrator_run/mod.rs | 206 +- .../src/orchestrator_run/snapshot.rs | 3 + crates/opensymphony-domain/src/lib.rs | 10 +- crates/opensymphony-domain/src/runtime.rs | 28 +- .../opensymphony-memory/src/capture_render.rs | 28 + crates/opensymphony-memory/src/index.rs | 96 +- crates/opensymphony-memory/src/lib.rs | 152 +- crates/opensymphony-memory/src/query.rs | 62 +- crates/opensymphony-openhands/src/session.rs | 230 +- .../src/hierarchy.rs | 23 + crates/opensymphony-orchestrator/src/lib.rs | 8 + .../src/parent_integration.rs | 2208 +++++++++++++++++ .../src/scheduler.rs | 1656 +++++++++++-- .../tests/scheduler.rs | 1801 ++++++++++++-- crates/opensymphony-workspace/src/lib.rs | 5 +- crates/opensymphony-workspace/src/manager.rs | 93 +- .../tests/workspace_manager.rs | 54 +- docs/architecture.md | 56 + docs/configuration.md | 8 + docs/memory.md | 59 +- docs/testing-and-operations.md | 24 +- docs/websocket-runtime.md | 3 + docs/workspace-and-lifecycle.md | 79 + 25 files changed, 8514 insertions(+), 582 deletions(-) create mode 100644 crates/opensymphony-orchestrator/src/parent_integration.rs diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index a14d85aa0..1cc688aca 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -62,9 +62,12 @@ use crate::{ ConversationMoveOutcome, ConversationStoreKind, IssueConversationManifest, OpenHandsConversationStorePaths, }, + opensymphony_orchestrator::{ + DurableOrchestratorState, ParentAttemptStatus, ParentIntegrationState, + }, opensymphony_workflow::{ResolvedWorkflow, WorkflowDefinition}, opensymphony_workspace::{ - CleanupConfig, HookConfig, IssueManifest, RunManifest, WorkspaceManager, + CleanupConfig, HookConfig, IssueManifest, RunManifest, RunStatus, WorkspaceManager, WorkspaceManagerConfig, checkout_workspace_key, workspace_path_for_root, }, }; @@ -512,6 +515,7 @@ impl AutoMemoryReport { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalCaptureBinding { + pub(crate) parent_integration: bool, pub(crate) repository_id: String, pub(crate) run_id: String, pub(crate) attempt: u32, @@ -519,6 +523,7 @@ pub(crate) struct TerminalCaptureBinding { pub(crate) target_commit: String, pub(crate) checkout_head: String, pub(crate) instruction_hash: String, + pub(crate) repository_commits: BTreeMap, } /// Read durable run envelopes before terminal capture so repository ownership @@ -535,7 +540,7 @@ pub(crate) fn load_terminal_capture_bindings( load_terminal_capture_bindings_inner(workspace_root, Some(&requested)) } -/// Read every durable run envelope before terminal cleanup can remove its workspace. +/// Read every durable run envelope from retained leaf and parent workspaces. pub(crate) fn load_all_terminal_capture_bindings( workspace_root: &Path, ) -> Result, MemoryError> { @@ -553,18 +558,7 @@ fn load_terminal_capture_bindings_inner( source, })?; let mut bindings = BTreeMap::new(); - for entry in fs::read_dir(&root).map_err(|source| MemoryError::ReadFile { - path: root.clone(), - source, - })? { - let entry = entry.map_err(|source| MemoryError::ReadFile { - path: root.clone(), - source, - })?; - let candidate = entry.path(); - if !candidate.is_dir() { - continue; - } + for candidate in terminal_capture_workspace_candidates(&root)? { let run_path = candidate.join(".opensymphony/run.json"); let Ok(raw) = fs::read_to_string(&run_path) else { continue; @@ -576,6 +570,70 @@ fn load_terminal_capture_bindings_inner( if requested.is_some_and(|requested| !requested.contains(&key)) { continue; } + if let Some(envelope) = run.parent_runtime_envelope.as_ref() { + if envelope.run_id.trim().is_empty() || envelope.attempt == 0 { + continue; + } + let Some(repository_commits) = completed_parent_capture_commits(&root, &run, envelope)? + else { + continue; + }; + let workspace_path = + candidate + .canonicalize() + .map_err(|source| MemoryError::ResolvePath { + path: candidate.clone(), + source, + })?; + let manifest_workspace_path = + run.workspace_path + .canonicalize() + .map_err(|source| MemoryError::ResolvePath { + path: run.workspace_path.clone(), + source, + })?; + let envelope_workspace_path = + envelope.workspace_path.canonicalize().map_err(|source| { + MemoryError::ResolvePath { + path: envelope.workspace_path.clone(), + source, + } + })?; + if envelope.run_id != run.run_id + || envelope.attempt != run.attempt + || manifest_workspace_path != workspace_path + || envelope_workspace_path != workspace_path + || !workspace_path.starts_with(&root) + { + return Err(MemoryError::InvalidInput(format!( + "parent runtime envelope for `{}` does not match its durable run manifest", + run.identifier + ))); + } + let binding = TerminalCaptureBinding { + parent_integration: true, + repository_id: String::new(), + run_id: run.run_id.clone(), + attempt: run.attempt, + target_branch: String::new(), + target_commit: String::new(), + checkout_head: String::new(), + instruction_hash: envelope + .integration_instruction_hash + .clone() + .unwrap_or_default(), + repository_commits, + }; + if let Some(previous) = bindings.insert(key.clone(), binding.clone()) + && previous != binding + { + return Err(MemoryError::InvalidInput(format!( + "multiple immutable runtime envelopes were found for `{}`", + run.identifier + ))); + } + continue; + } let Some(envelope) = run.runtime_envelope.as_ref() else { continue; }; @@ -634,6 +692,7 @@ fn load_terminal_capture_bindings_inner( }) .unwrap_or_default(); let binding = TerminalCaptureBinding { + parent_integration: false, repository_id: envelope.repository_binding.repository.id.to_string(), run_id: run.run_id.clone(), attempt: run.attempt, @@ -641,6 +700,7 @@ fn load_terminal_capture_bindings_inner( target_commit: envelope.target_commit.clone(), checkout_head, instruction_hash: envelope.instruction.content_hash.clone(), + repository_commits: BTreeMap::new(), }; if let Some(previous) = bindings.insert(key.clone(), binding.clone()) && previous != binding @@ -654,6 +714,141 @@ fn load_terminal_capture_bindings_inner( Ok(bindings) } +fn terminal_capture_workspace_candidates(root: &Path) -> Result, MemoryError> { + let parents_root = root.join("parents"); + let mut candidates = child_directories(root)?; + candidates.retain(|candidate| candidate != &parents_root); + for parent_key in child_directories_if_present(&parents_root)? { + candidates.extend(child_directories(&parent_key)?); + } + Ok(candidates) +} + +fn child_directories_if_present(path: &Path) -> Result, MemoryError> { + match fs::symlink_metadata(path) { + Ok(metadata) if !metadata.file_type().is_dir() => Ok(Vec::new()), + Ok(_) => child_directories(path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()), + Err(source) => Err(MemoryError::ReadFile { + path: path.to_path_buf(), + source, + }), + } +} + +fn child_directories(path: &Path) -> Result, MemoryError> { + let entries = fs::read_dir(path).map_err(|source| MemoryError::ReadFile { + path: path.to_path_buf(), + source, + })?; + child_directories_from_entries(path, entries) +} + +fn child_directories_from_entries( + path: &Path, + entries: fs::ReadDir, +) -> Result, MemoryError> { + let mut children = Vec::new(); + for entry in entries { + let entry = entry.map_err(|source| MemoryError::ReadFile { + path: path.to_path_buf(), + source, + })?; + let file_type = entry.file_type().map_err(|source| MemoryError::ReadFile { + path: entry.path(), + source, + })?; + if file_type.is_dir() { + children.push(entry.path()); + } + } + children.sort(); + Ok(children) +} + +fn completed_parent_capture_commits( + workspace_root: &Path, + run: &RunManifest, + envelope: &crate::opensymphony_workspace::ParentRuntimeEnvelope, +) -> Result>, MemoryError> { + if run.status != RunStatus::Succeeded { + return Ok(None); + } + let state_path = workspace_root.join(".opensymphony-orchestrator-state.json"); + let raw = match fs::read_to_string(&state_path) { + Ok(raw) => raw, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(MemoryError::ReadFile { + path: state_path, + source, + }); + } + }; + let state: DurableOrchestratorState = serde_json::from_str(&raw).map_err(|error| { + MemoryError::InvalidInput(format!( + "failed to decode durable parent controller state before terminal capture: {error}" + )) + })?; + state.validate().map_err(|error| { + MemoryError::InvalidInput(format!( + "invalid durable parent controller state before terminal capture: {error}" + )) + })?; + let Some((_, controller)) = state + .parent_integrations + .iter() + .find(|(issue_id, _)| issue_id.as_str() == envelope.parent_issue_id) + else { + return Ok(None); + }; + if controller.state != ParentIntegrationState::Completed + || controller.hierarchy_generation != envelope.hierarchy_generation + { + return Ok(None); + } + let Some(final_evidence) = controller.final_evidence.as_ref() else { + return Ok(None); + }; + let expected_conversation = envelope.conversation_binding.as_deref(); + if expected_conversation.is_none() + || expected_conversation != Some(final_evidence.conversation_id.as_str()) + { + return Ok(None); + } + let expected_attempt_key = format!( + "parent-run:{}", + run.run_id.strip_prefix("run-").unwrap_or(&run.run_id) + ); + let Some(_attempt) = controller.attempts.iter().find(|attempt| { + attempt.id == final_evidence.attempt_id + && attempt.status == ParentAttemptStatus::Passed + && attempt.idempotency_key == expected_attempt_key + && attempt.input_version == final_evidence.input_version + }) else { + return Ok(None); + }; + let controller_commits = final_evidence + .repository_commits + .iter() + .map(|(repository_id, commit)| (repository_id.to_string(), commit.clone())) + .collect::>(); + let envelope_commits = envelope + .checkouts + .values() + .map(|checkout| { + ( + checkout.repository_id.clone(), + checkout.target_commit.clone(), + ) + }) + .collect::>(); + if controller_commits != envelope_commits { + return Ok(None); + } + Ok(Some(controller_commits)) +} + fn apply_terminal_capture_bindings( source: &mut SourceFile, bindings: &BTreeMap, @@ -662,13 +857,20 @@ fn apply_terminal_capture_bindings( let Some(binding) = bindings.get(&issue.identifier.to_ascii_lowercase()) else { continue; }; - issue.repository_id = Some(binding.repository_id.clone()); + issue.parent_integration = binding.parent_integration; + issue.repository_id = + (!binding.repository_id.is_empty()).then(|| binding.repository_id.clone()); issue.execution_run_id = Some(binding.run_id.clone()); issue.execution_attempt = Some(binding.attempt); - issue.target_branch = Some(binding.target_branch.clone()); - issue.target_commit = Some(binding.target_commit.clone()); - issue.checkout_head = Some(binding.checkout_head.clone()); - issue.instruction_hash = Some(binding.instruction_hash.clone()); + issue.target_branch = + (!binding.target_branch.is_empty()).then(|| binding.target_branch.clone()); + issue.target_commit = + (!binding.target_commit.is_empty()).then(|| binding.target_commit.clone()); + issue.checkout_head = + (!binding.checkout_head.is_empty()).then(|| binding.checkout_head.clone()); + issue.instruction_hash = + (!binding.instruction_hash.is_empty()).then(|| binding.instruction_hash.clone()); + issue.verified_repository_commits = binding.repository_commits.clone(); } } @@ -706,7 +908,8 @@ pub(crate) async fn auto_capture_terminal( if let Some(bindings) = capture_bindings { apply_terminal_capture_bindings(&mut source, bindings); } - let repository_groups = auto_capture_repository_groups(&config, &source, &identifiers)?; + let repository_groups = + auto_capture_repository_groups(&config, &source, &identifiers, capture_bindings)?; if repository_groups.len() > 1 { let mut aggregate = AutoMemoryReport { capture_completed: true, @@ -749,7 +952,8 @@ pub(crate) async fn auto_capture_terminal( identifiers, ..IssueSelection::default() }; - let capture_config = resolve_auto_capture_repository_config(&config, &source, &selection)?; + let capture_config = + resolve_auto_capture_repository_config(&config, &source, &selection, capture_bindings)?; let mut capture_plan = plan_capture(&capture_config, &source, &selection, true, true)?; let issue_keys = capture_plan .selected @@ -941,6 +1145,7 @@ fn resolve_auto_capture_repository_config( config: &MemoryConfig, source: &SourceFile, selection: &IssueSelection, + capture_bindings: Option<&BTreeMap>, ) -> Result { let issue_ids = selection .identifiers @@ -951,8 +1156,25 @@ fn resolve_auto_capture_repository_config( .issues .iter() .filter(|issue| issue_ids.contains(&issue.identifier.to_ascii_lowercase())) - .flat_map(|issue| auto_capture_candidate_repositories(config, issue)) + .flat_map(|issue| { + auto_capture_candidate_repositories( + config, + issue, + capture_binding_is_parent(capture_bindings, &issue.identifier) + || !issue.verified_repository_commits.is_empty(), + ) + }) .collect::>(); + let selected_issue_is_parent_capture = source.issues.iter().any(|issue| { + issue_ids.contains(&issue.identifier.to_ascii_lowercase()) + && (capture_binding_is_parent(capture_bindings, &issue.identifier) + || !issue.verified_repository_commits.is_empty()) + }); + if selected_issue_is_parent_capture && candidate_repositories.is_empty() { + let mut routed = config.clone(); + routed.default_repository_id = None; + return Ok(routed); + } let repository_id = if candidate_repositories.len() == 1 { candidate_repositories.into_iter().next() } else if candidate_repositories.is_empty() { @@ -965,12 +1187,13 @@ fn resolve_auto_capture_repository_config( "cannot auto-capture a terminal issue without a unique repository source" .to_string(), )); + } else { + config.default_repository_id.clone().or_else(|| { + (config.repository_sources.len() == 1) + .then(|| config.repository_sources.keys().next().cloned()) + .flatten() + }) } - config.default_repository_id.clone().or_else(|| { - (config.repository_sources.len() == 1) - .then(|| config.repository_sources.keys().next().cloned()) - .flatten() - }) } else { None }; @@ -1005,7 +1228,11 @@ fn resolve_auto_capture_repository_config( fn auto_capture_candidate_repositories( config: &MemoryConfig, issue: &IssueEvidence, + parent_integration: bool, ) -> BTreeSet { + if parent_integration { + return BTreeSet::new(); + } if let Some(repository_id) = issue.repository_id.as_ref() { return BTreeSet::from([repository_id.clone()]); } @@ -1026,6 +1253,7 @@ fn auto_capture_repository_groups( config: &MemoryConfig, source: &SourceFile, identifiers: &[String], + capture_bindings: Option<&BTreeMap>, ) -> Result, Vec>, MemoryError> { let mut groups = BTreeMap::new(); for identifier in identifiers { @@ -1034,7 +1262,12 @@ fn auto_capture_repository_groups( .iter() .find(|issue| issue.identifier.eq_ignore_ascii_case(identifier)) .and_then(|issue| { - let candidates = auto_capture_candidate_repositories(config, issue); + let candidates = auto_capture_candidate_repositories( + config, + issue, + capture_binding_is_parent(capture_bindings, identifier) + || !issue.verified_repository_commits.is_empty(), + ); if candidates.len() > 1 { return None; } @@ -1055,7 +1288,12 @@ fn auto_capture_repository_groups( else { continue; }; - let candidates = auto_capture_candidate_repositories(config, issue); + let candidates = auto_capture_candidate_repositories( + config, + issue, + capture_binding_is_parent(capture_bindings, identifier) + || !issue.verified_repository_commits.is_empty(), + ); if candidates.len() > 1 { return Err(MemoryError::InvalidInput(format!( "cannot auto-capture `{}` because its project scope matches multiple repository sources", @@ -1066,6 +1304,15 @@ fn auto_capture_repository_groups( Ok(groups) } +fn capture_binding_is_parent( + bindings: Option<&BTreeMap>, + identifier: &str, +) -> bool { + bindings + .and_then(|bindings| bindings.get(&identifier.to_ascii_lowercase())) + .is_some_and(|binding| binding.parent_integration) +} + async fn run_memory(args: MemoryArgs) -> Result<(), MemoryError> { let repo_root = env::current_dir().map_err(|source| MemoryError::ReadFile { path: PathBuf::from("."), @@ -2135,12 +2382,22 @@ struct MemoryScopeGrantRegistryState { revoked_issues: HashSet, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct MemoryLiveOverlayGrant { + pub(crate) parent_workspace_path: PathBuf, + pub(crate) checkout_handle: String, + pub(crate) relative_path: PathBuf, + pub(crate) target_commit: String, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct MemoryScopeGrant { pub(crate) project: String, pub(crate) project_set: Option, pub(crate) execution_repo: String, pub(crate) authorized_repositories: BTreeSet, + pub(crate) authorized_work_items: BTreeSet, + pub(crate) live_overlays: BTreeMap, pub(crate) issue: String, pub(crate) run_id: Option, pub(crate) attempt: Option, @@ -2196,6 +2453,51 @@ impl MemoryScopeGrantRegistry { (token, rotated || requires_fresh_conversation) } + pub(crate) fn issue_or_refresh_parent_claims(&self, grant: MemoryScopeGrant) -> (String, bool) { + let mut state = self.state.write().expect("memory grant registry poisoned"); + let requires_fresh_conversation = state.revoked_issues.contains(&grant.issue); + if let Some(token) = state + .grants + .iter() + .find(|(_, existing)| existing.issue == grant.issue) + .map(|(token, _)| token.clone()) + { + state.grants.insert(token.clone(), grant); + return (token, requires_fresh_conversation); + } + let token = format!("opensymphony-worker-{}", Uuid::new_v4()); + state.grants.insert(token.clone(), grant); + (token, requires_fresh_conversation) + } + + pub(crate) fn restore_parent_claims( + &self, + token: &str, + grant: MemoryScopeGrant, + ) -> Result { + if !token.starts_with("opensymphony-worker-") { + return Err( + "recovered parent memory bearer is not an OpenSymphony worker token".into(), + ); + } + let mut state = self.state.write().expect("memory grant registry poisoned"); + if state.revoked_issues.contains(&grant.issue) { + return Err("recovered parent memory bearer belongs to a revoked issue".into()); + } + if state + .grants + .get(token) + .is_some_and(|existing| existing.issue != grant.issue) + { + return Err("recovered parent memory bearer is already bound to another issue".into()); + } + state.grants.retain(|existing_token, existing| { + existing.issue != grant.issue || existing_token == token + }); + state.grants.insert(token.to_owned(), grant); + Ok(token.to_owned()) + } + pub(crate) fn acknowledge_fresh_conversation(&self, issue: &str) { self.state .write() @@ -2284,6 +2586,7 @@ async fn run_serve( pub(crate) struct MemoryServerHandle { endpoint: String, + local_addr: SocketAddr, visibility: MemoryVisibility, task: Option>>, shutdown: watch::Sender, @@ -2302,6 +2605,10 @@ impl MemoryServerHandle { &self.endpoint } + pub(crate) fn local_addr(&self) -> SocketAddr { + self.local_addr + } + pub(crate) fn visibility(&self) -> MemoryVisibility { self.visibility } @@ -2508,6 +2815,7 @@ async fn start_memory_server_with_auth( }); Ok(MemoryServerHandle { endpoint: format!("http://{local_addr}/mcp"), + local_addr, visibility, task: Some(task), shutdown, @@ -3885,6 +4193,12 @@ async fn call_memory_tool_with_workspace( && (bool_arg(&arguments, "includeCodeIntel") || bool_arg(&arguments, "include_code_intel")))) { + if grant.execution_repo.is_empty() { + return Err(MemoryError::InvalidInput( + "repository-neutral parent code access requires an explicit repository" + .to_owned(), + )); + } scope.repo = Some(grant.execution_repo.clone()); } if workspace_root.is_none() @@ -4162,10 +4476,9 @@ fn validate_worker_memory_scope( .get("currentIssue") .and_then(|current| optional_string_arg(current, "identifier")) }); - if requested_work_item - .as_deref() - .is_some_and(|work_item| work_item != grant.issue) - { + if requested_work_item.as_deref().is_some_and(|work_item| { + work_item != grant.issue && !grant.authorized_work_items.contains(work_item) + }) { return Err(MemoryError::InvalidInput(format!( "worker memory grant is bound to work item `{}`; requested work item is not permitted", grant.issue @@ -4226,15 +4539,15 @@ fn validate_worker_memory_scope( }; if tool_name.starts_with("code.") && requested_repo != grant.execution_repo + && !grant.live_overlays.contains_key(&requested_repo) && tool_name == "code.graph.context" && optional_string_arg(arguments, "runId") .or_else(|| optional_string_arg(arguments, "run")) .is_some() { - return Err(MemoryError::InvalidInput(format!( - "worker live code access is limited to execution repository `{}`", - grant.execution_repo - ))); + return Err(MemoryError::InvalidInput( + "worker live code access is outside the current execution checkouts".to_owned(), + )); } if !grant.authorized_repositories.contains(&requested_repo) { return Err(MemoryError::InvalidInput( @@ -4260,10 +4573,9 @@ fn validate_worker_memory_scope( "worker live code graph access is bound to the current run".to_owned(), )); } - } else if requested_issue - .as_deref() - .is_some_and(|issue| issue != grant.issue) - { + } else if requested_issue.as_deref().is_some_and(|issue| { + issue != grant.issue && !grant.authorized_work_items.contains(issue) + }) { return Err(MemoryError::InvalidInput(format!( "worker memory grant is bound to issue `{}`; requested code scope is not permitted", grant.issue @@ -4387,6 +4699,22 @@ fn resolve_code_graph_overlay_with_grant( path: workspace_root.to_path_buf(), source, })?; + if let Some((grant, overlay)) = worker_grant.and_then(|grant| { + grant + .live_overlays + .get(repo_id) + .map(|overlay| (grant, overlay)) + }) { + return resolve_parent_code_graph_overlay( + config, + &workspace_root, + repo_id, + run_id, + context_query, + grant, + overlay, + ); + } let workspace_candidate = if strict_checkout { find_verified_checkout_for_code_intel_with_claims( &workspace_root, @@ -4534,6 +4862,146 @@ fn resolve_code_graph_overlay_with_grant( .map_err(|error| MemoryError::InvalidInput(error.to_string())) } +fn resolve_parent_code_graph_overlay( + config: &MemoryConfig, + workspace_root: &Path, + repo_id: &str, + run_id: &str, + context_query: &CodeGraphContextQuery, + grant: &MemoryScopeGrant, + overlay: &MemoryLiveOverlayGrant, +) -> Result { + let parent_path = overlay + .parent_workspace_path + .canonicalize() + .map_err(|source| MemoryError::ResolvePath { + path: overlay.parent_workspace_path.clone(), + source, + })?; + if !parent_path.starts_with(workspace_root) { + return Err(MemoryError::PathOutsideRepo { + path: parent_path, + repo_root: workspace_root.to_path_buf(), + }); + } + let run_path = parent_path.join(".opensymphony/run.json"); + let raw = fs::read_to_string(&run_path).map_err(|source| MemoryError::ReadFile { + path: run_path, + source, + })?; + let run: RunManifest = serde_json::from_str(&raw).map_err(|source| { + MemoryError::InvalidInput(format!("invalid parent run manifest: {source}")) + })?; + let envelope = run.parent_runtime_envelope.as_ref().ok_or_else(|| { + MemoryError::InvalidInput("durable run manifest has no parent runtime envelope".to_owned()) + })?; + let mut matching_checkouts = envelope + .checkouts + .values() + .filter(|checkout| checkout.repository_id == repo_id); + let checkout = matching_checkouts.next().ok_or_else(|| { + MemoryError::InvalidInput( + "requested repository is absent from the parent runtime envelope".to_owned(), + ) + })?; + if matching_checkouts.next().is_some() { + return Err(MemoryError::InvalidInput( + "requested repository has multiple parent runtime checkouts".to_owned(), + )); + } + if grant.run_id.as_deref() != Some(run_id) + || run.run_id != run_id + || grant.attempt != Some(run.attempt) + || envelope.run_id != run.run_id + || envelope.attempt != run.attempt + || envelope.workspace_path != overlay.parent_workspace_path + || checkout.checkout_handle != overlay.checkout_handle + || checkout.relative_path != overlay.relative_path + || checkout.target_commit != overlay.target_commit + { + return Err(MemoryError::InvalidInput( + "parent runtime envelope does not match the worker overlay claim".to_owned(), + )); + } + let checkout_path = parent_path.join(&overlay.relative_path); + if fs::symlink_metadata(&checkout_path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(MemoryError::InvalidInput( + "parent checkout must not be a symlink".to_owned(), + )); + } + let checkout_path = + checkout_path + .canonicalize() + .map_err(|source| MemoryError::ResolvePath { + path: checkout_path, + source, + })?; + if !checkout_path.starts_with(&parent_path) { + return Err(MemoryError::PathOutsideRepo { + path: checkout_path, + repo_root: parent_path, + }); + } + let source = config.repository_sources.get(repo_id).ok_or_else(|| { + MemoryError::InvalidInput(format!("unknown parent repository source `{repo_id}`")) + })?; + if !workspace_matches_registered_repository(&checkout_path, &source.root) { + return Err(MemoryError::InvalidInput( + "parent checkout does not match the registered repository".to_owned(), + )); + } + verify_parent_checkout_target_ancestry(&checkout_path, &overlay.target_commit)?; + let branch = code_index_branch_for_config(config) + .map_err(|error| MemoryError::InvalidInput(error.to_string()))?; + let base_revision = workspace_merge_base(&checkout_path, &branch)?; + code_graph_workspace_context_overlay( + config, + repo_id, + &checkout_path, + run_id, + &base_revision, + context_query, + ) + .map_err(|error| MemoryError::InvalidInput(error.to_string())) +} + +fn verify_parent_checkout_target_ancestry( + checkout_path: &Path, + target_commit: &str, +) -> Result<(), MemoryError> { + let head = process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(checkout_path) + .output() + .map_err(|source| { + MemoryError::InvalidInput(format!("failed to inspect parent checkout HEAD: {source}")) + })?; + if !head.status.success() { + return Err(MemoryError::InvalidInput( + "parent checkout HEAD could not be resolved".to_owned(), + )); + } + let head = String::from_utf8_lossy(&head.stdout).trim().to_owned(); + let target_is_ancestor = process::Command::new("git") + .args(["merge-base", "--is-ancestor", target_commit, head.as_str()]) + .current_dir(checkout_path) + .status() + .map_err(|source| { + MemoryError::InvalidInput(format!( + "failed to verify parent checkout ancestry: {source}" + )) + })?; + if !target_is_ancestor.success() { + return Err(MemoryError::InvalidInput( + "parent checkout HEAD no longer descends from its verified target commit".to_owned(), + )); + } + Ok(()) +} + fn workspace_merge_base(workspace_path: &Path, branch: &str) -> Result { for reference in [format!("origin/{branch}"), branch.to_string()] { let verified = process::Command::new("git") @@ -6980,6 +7448,7 @@ where all_accessible: scope.all_accessible, project_id_only: false, authorized_repositories: None, + authorized_work_items: None, max_visibility: None, } } @@ -7094,6 +7563,9 @@ fn worker_scope_filter_from_mcp( } scope.project_id_only = true; scope.authorized_repositories = Some(grant.authorized_repositories.clone()); + let mut authorized_work_items = grant.authorized_work_items.clone(); + authorized_work_items.insert(grant.issue.clone()); + scope.authorized_work_items = Some(authorized_work_items); let requested_visibility = optional_string_arg(arguments, "visibility") .map( |visibility| match visibility.to_ascii_lowercase().as_str() { @@ -7145,6 +7617,7 @@ where all_accessible, project_id_only: false, authorized_repositories: None, + authorized_work_items: None, max_visibility: None, }; if !scope.all_accessible @@ -8581,14 +9054,18 @@ fn print_search_results( #[cfg(test)] mod tests { - use std::{collections::BTreeSet, path::PathBuf}; + use std::{ + collections::{BTreeMap, BTreeSet}, + path::PathBuf, + }; use super::{ - LINEAR_MEMORY_STATUS_BEGIN, LINEAR_MEMORY_STATUS_END, MemoryMcpRequest, MemoryScopeGrant, - MemoryScopeGrantRegistry, MemoryServerAccess, MemoryServerAuth, MemoryServerState, - RUST_QUERY_PACK_VERSION, acquire_memory_writer_lock, authorize_memory_request, - brief_scope_filter, call_code_graph_context_tool, call_memory_ingest_code_intel_tool, - call_memory_tool, call_memory_tool_with_workspace, context_source_from_mcp, + LINEAR_MEMORY_STATUS_BEGIN, LINEAR_MEMORY_STATUS_END, MemoryLiveOverlayGrant, + MemoryMcpRequest, MemoryScopeGrant, MemoryScopeGrantRegistry, MemoryServerAccess, + MemoryServerAuth, MemoryServerState, RUST_QUERY_PACK_VERSION, acquire_memory_writer_lock, + authorize_memory_request, authorize_memory_request_with_scoped_grant, brief_scope_filter, + call_code_graph_context_tool, call_memory_ingest_code_intel_tool, call_memory_tool, + call_memory_tool_with_workspace, context_source_from_mcp, find_verified_checkout_for_code_intel, find_verified_checkout_for_code_intel_with_claims, load_memory_config, memory_server_health, memory_server_health_payload, memory_tool_descriptors, origin_is_localhost, parse_remote_memory_response, @@ -8596,16 +9073,17 @@ mod tests { remote_memory_tool_token, replace_or_append_managed_section, required_access_for_request, resolve_code_graph_overlay, resolve_code_intel_config, resolve_code_intel_repo, run_init, sha256_file_hex, trim_auto_memory_status_log, validate_worker_memory_scope, + verify_parent_checkout_target_ancestry, }; use crate::opensymphony_memory::{ CodeGraphContextQuery, CodeIntelDiagnosticInput, CodeIntelDocumentInput, CodeIntelEdgeInput, CodeIntelPersistBatch, CodeIntelSymbolInput, CodeSymbolDiffStatus, - IssueEvidence, IssueSelection, MemoryConfig, MemoryError, MemoryRepositorySource, - MemoryScopeFilter, MemorySourceKind, MemorySourceRegistrationStatus, - RegisteredMemorySource, SourceFile, code_symbol_detail, code_symbol_neighborhood, - code_symbols_containing_span, compare_code_symbols, load_issue_capsule_with_scope, - persist_code_intel_documents, plan_capture, register_memory_source, - registered_memory_sources, write_capture_plan, + IssueEvidence, IssueSelection, KnowledgeScope, KnowledgeScopeKind, MemoryConfig, + MemoryError, MemoryRepositorySource, MemoryScopeFilter, MemorySourceKind, + MemorySourceRegistrationStatus, RegisteredMemorySource, SourceFile, code_symbol_detail, + code_symbol_neighborhood, code_symbols_containing_span, compare_code_symbols, + load_issue_capsule_with_scope, persist_code_intel_documents, plan_capture, + register_memory_source, registered_memory_sources, write_capture_plan, }; use crate::opensymphony_workspace::{ IssueManifest, RunManifest, RunStatus, checkout_workspace_key, @@ -8705,6 +9183,7 @@ mod tests { identifiers: vec!["COE-550".to_string()], ..IssueSelection::default() }, + None, ) .expect("routed config"); assert_eq!(routed.default_repository_id.as_deref(), Some("repo-b")); @@ -13427,6 +13906,7 @@ Public memory concept. "repo-beta".to_string(), ]), issue: "COE-551".to_string(), + authorized_work_items: BTreeSet::from(["COE-552".to_string()]), ..MemoryScopeGrant::default() }; @@ -13438,6 +13918,13 @@ Public memory concept. ) .expect("repository alias should select the requested sibling"); assert_eq!(scope.repo.as_deref(), Some("repo-beta")); + assert_eq!( + scope.authorized_work_items, + Some(BTreeSet::from([ + "COE-551".to_string(), + "COE-552".to_string() + ])) + ); } #[test] @@ -13581,6 +14068,57 @@ Public memory concept. .expect("code scope should remain bound to the worker issue"); validate_worker_memory_scope("code.graph.context", &json!({"repo": "repo-alpha"}), &grant) .expect("baseline graph scope should not require a run overlay"); + + let parent_grant = MemoryScopeGrant { + project: "project-alpha".to_owned(), + execution_repo: "repo-alpha".to_owned(), + authorized_repositories: BTreeSet::from([ + "repo-alpha".to_owned(), + "repo-beta".to_owned(), + ]), + authorized_work_items: BTreeSet::from(["COE-548".to_owned()]), + live_overlays: BTreeMap::from([( + "repo-beta".to_owned(), + MemoryLiveOverlayGrant { + parent_workspace_path: PathBuf::from("/parent"), + checkout_handle: "checkout-beta".to_owned(), + relative_path: PathBuf::from("repositories/repo-beta"), + target_commit: "beta-commit".to_owned(), + }, + )]), + issue: "COE-547".to_owned(), + run_id: Some("parent-run".to_owned()), + attempt: Some(1), + ..MemoryScopeGrant::default() + }; + validate_worker_memory_scope( + "code.graph.context", + &json!({"repo": "repo-beta", "runId": "parent-run"}), + &parent_grant, + ) + .expect("parent live graph access should allow an envelope checkout"); + validate_worker_memory_scope( + "memory.context", + &json!({"repo": "repo-beta", "issue": "COE-548"}), + &parent_grant, + ) + .expect("parent memory should allow a recorded descendant"); + assert!( + validate_worker_memory_scope( + "code.graph.context", + &json!({"repo": "repo-gamma", "runId": "parent-run"}), + &parent_grant, + ) + .is_err() + ); + assert!( + validate_worker_memory_scope( + "memory.context", + &json!({"repo": "repo-beta", "issue": "COE-999"}), + &parent_grant, + ) + .is_err() + ); } #[test] @@ -13615,6 +14153,81 @@ Public memory concept. ); } + #[test] + fn parent_memory_grant_refresh_keeps_conversation_bearer() { + let registry = MemoryScopeGrantRegistry::default(); + let (first, first_requires_fresh) = + registry.issue_or_refresh_parent_claims(MemoryScopeGrant { + issue: "COE-554".to_owned(), + run_id: Some("run-1".to_owned()), + attempt: Some(1), + ..MemoryScopeGrant::default() + }); + let (refreshed, refreshed_requires_fresh) = + registry.issue_or_refresh_parent_claims(MemoryScopeGrant { + issue: "COE-554".to_owned(), + run_id: Some("run-2".to_owned()), + attempt: Some(2), + ..MemoryScopeGrant::default() + }); + + assert_eq!(refreshed, first); + assert!(!first_requires_fresh); + assert!(!refreshed_requires_fresh); + assert_eq!( + registry + .get(Some(&refreshed)) + .expect("refreshed parent grant") + .run_id + .as_deref(), + Some("run-2") + ); + } + + #[test] + fn parent_memory_grant_restart_restores_the_conversation_bearer() { + let registry = MemoryScopeGrantRegistry::default(); + let bearer = "opensymphony-worker-parent-before-restart"; + let restored = registry + .restore_parent_claims( + bearer, + MemoryScopeGrant { + issue: "COE-554".to_owned(), + run_id: Some("run-after-restart".to_owned()), + attempt: Some(2), + ..MemoryScopeGrant::default() + }, + ) + .expect("restore parent bearer"); + + assert_eq!(restored, bearer); + let restored_grant = registry + .get(Some(bearer)) + .expect("restored bearer authorizes current claims"); + assert_eq!(restored_grant.run_id.as_deref(), Some("run-after-restart")); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + format!("Bearer {bearer}").parse().expect("authorization"), + ); + authorize_memory_request_with_scoped_grant( + &headers, + &MemoryServerAuth::default(), + MemoryServerAccess::Read, + Some(&restored_grant), + ) + .expect("the existing conversation bearer authenticates after restart"); + let (refreshed, requires_fresh) = + registry.issue_or_refresh_parent_claims(MemoryScopeGrant { + issue: "COE-554".to_owned(), + run_id: Some("run-continuation".to_owned()), + attempt: Some(3), + ..MemoryScopeGrant::default() + }); + assert_eq!(refreshed, bearer); + assert!(!requires_fresh); + } + #[test] fn worker_memory_grant_can_be_revoked_at_issue_lifecycle_boundary() { let registry = MemoryScopeGrantRegistry::default(); @@ -13826,6 +14439,53 @@ Public memory concept. ); } + #[test] + fn parent_overlay_requires_the_verified_target_to_remain_in_head_ancestry() { + let checkout = TempDir::new().expect("checkout"); + std::fs::write(checkout.path().join("README.md"), "baseline\n").expect("baseline"); + init_test_git_repo(checkout.path(), "develop"); + let target = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(checkout.path()) + .output() + .expect("target") + .stdout; + let target = String::from_utf8(target).expect("utf8").trim().to_owned(); + std::fs::write(checkout.path().join("README.md"), "descendant\n").expect("descendant"); + for args in [&["add", "."][..], &["commit", "-m", "descendant"][..]] { + assert!( + std::process::Command::new("git") + .args(args) + .current_dir(checkout.path()) + .status() + .expect("git command") + .success() + ); + } + verify_parent_checkout_target_ancestry(checkout.path(), &target) + .expect("descendant checkout remains valid"); + + assert!( + std::process::Command::new("git") + .args(["checkout", "--orphan", "unrelated"]) + .current_dir(checkout.path()) + .status() + .expect("orphan checkout") + .success() + ); + assert!( + std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "unrelated"]) + .current_dir(checkout.path()) + .status() + .expect("unrelated commit") + .success() + ); + let error = verify_parent_checkout_target_ancestry(checkout.path(), &target) + .expect_err("unrelated head must be rejected"); + assert!(error.to_string().contains("no longer descends")); + } + #[test] fn code_intel_repo_resolution_uses_registered_canonical_identity() { let instance = TempDir::new().expect("instance"); @@ -14006,6 +14666,7 @@ Public memory concept. &config, &source, &["COE-1".to_string(), "COE-2".to_string()], + None, ) .expect("unambiguous repository groups"); assert_eq!( @@ -14050,7 +14711,7 @@ Public memory concept. ..SourceFile::default() }; let groups = - super::auto_capture_repository_groups(&config, &source, &["COE-551".to_string()]) + super::auto_capture_repository_groups(&config, &source, &["COE-551".to_string()], None) .expect("explicit runtime owner should disambiguate capture"); assert_eq!( groups.get(&Some("repo-b".to_string())), @@ -14063,12 +14724,131 @@ Public memory concept. identifiers: vec!["COE-551".to_string()], ..IssueSelection::default() }, + None, ) .expect("runtime owner should route docs and capture"); assert_eq!(routed.default_repository_id.as_deref(), Some("repo-b")); assert_eq!(routed.repo_root, repository_b.path()); } + #[test] + fn auto_capture_keeps_empty_target_parent_repository_neutral() { + let catalog = TempDir::new().expect("catalog"); + let repository_a = TempDir::new().expect("repository a"); + let repository_b = TempDir::new().expect("repository b"); + let mut config = MemoryConfig::load(catalog.path(), None).expect("config"); + config.default_repository_id = Some("repo-a".to_owned()); + for (repository_id, root) in [ + ("repo-a", repository_a.path()), + ("repo-b", repository_b.path()), + ] { + config.repository_sources.insert( + repository_id.to_owned(), + MemoryRepositorySource { + repository_id: repository_id.to_owned(), + root: root.to_path_buf(), + commit_sha: None, + project_scope_ids: BTreeSet::from(["shared-project".to_owned()]), + target_branch: None, + }, + ); + } + let mut source = SourceFile { + issues: vec![IssueEvidence { + identifier: "COE-PARENT".to_owned(), + project_id: Some("shared-project".to_owned()), + execution_run_id: Some("run-parent".to_owned()), + verified_repository_commits: BTreeMap::new(), + ..IssueEvidence::default() + }], + ..SourceFile::default() + }; + let bindings = BTreeMap::from([( + "coe-parent".to_owned(), + super::TerminalCaptureBinding { + parent_integration: true, + repository_id: String::new(), + run_id: "run-parent".to_owned(), + attempt: 1, + target_branch: String::new(), + target_commit: String::new(), + checkout_head: String::new(), + instruction_hash: "sha256:parent".to_owned(), + repository_commits: BTreeMap::new(), + }, + )]); + super::apply_terminal_capture_bindings(&mut source, &bindings); + assert!(source.issues[0].parent_integration); + + let groups = super::auto_capture_repository_groups( + &config, + &source, + &["COE-PARENT".to_owned()], + Some(&bindings), + ) + .expect("empty-target parent should remain repository-neutral"); + assert_eq!(groups.get(&None), Some(&vec!["COE-PARENT".to_owned()])); + + let routed = super::resolve_auto_capture_repository_config( + &config, + &source, + &IssueSelection { + identifiers: vec!["COE-PARENT".to_owned()], + ..IssueSelection::default() + }, + Some(&bindings), + ) + .expect("empty-target parent should not be assigned a leaf repository"); + assert_eq!(routed.repo_root, config.repo_root); + assert_eq!(routed.default_repository_id, None); + + let plan = plan_capture( + &routed, + &source, + &IssueSelection { + identifiers: vec!["COE-PARENT".to_owned()], + ..IssueSelection::default() + }, + true, + false, + ) + .expect("empty-target parent capture plan"); + write_capture_plan(&routed, &plan, false).expect("empty-target parent capture"); + let connection = Connection::open(&routed.index_path).expect("memory index"); + let scope_refs_json: String = connection + .query_row( + "SELECT scope_refs_json FROM issues WHERE issue_key = 'COE-PARENT'", + [], + |row| row.get(0), + ) + .expect("captured parent scope refs"); + let scope_refs: Vec = + serde_json::from_str(&scope_refs_json).expect("scope refs JSON"); + assert!( + scope_refs + .iter() + .all(|scope| scope.kind != KnowledgeScopeKind::Repository), + "repository-neutral parent capture must not inherit a default repository" + ); + let source_refs_json: String = connection + .query_row( + "SELECT source_refs_json FROM issues WHERE issue_key = 'COE-PARENT'", + [], + |row| row.get(0), + ) + .expect("captured parent source refs"); + let source_refs: Vec = + serde_json::from_str(&source_refs_json).expect("source refs JSON"); + assert!(source_refs.iter().any(|source_ref| { + source_ref["kind"] == "parent_terminal_runtime_envelope" + && source_ref["id"] == "run=run-parent;attempt=1;repo=;target_commit=" + && source_ref["repo_id"].is_null() + })); + let capsule = std::fs::read_to_string(&plan.selected[0].capsule_path) + .expect("repository-neutral parent capsule"); + assert!(capsule.contains("run=run-parent;attempt=1;repo=;target_commit=")); + } + #[test] fn terminal_capture_bindings_follow_durable_runtime_envelope() { let workspace_root = TempDir::new().expect("workspace root"); @@ -14165,6 +14945,7 @@ Public memory concept. super::load_terminal_capture_bindings(workspace_root.path(), &["COE-551".to_string()]) .expect("durable capture binding"); assert_eq!(bindings["coe-551"].repository_id, "repo-b"); + assert!(!bindings["coe-551"].parent_integration); assert_eq!(bindings["coe-551"].run_id, "run-551"); assert_eq!(bindings["coe-551"].attempt, 2); assert_eq!(bindings["coe-551"].target_commit, "commit-551"); @@ -14175,6 +14956,263 @@ Public memory concept. .expect("all durable capture bindings"); assert_eq!(all_bindings, bindings); + let parent_workspace = workspace_root + .path() + .join("parents") + .join("COE-554-parent") + .join("4"); + std::fs::create_dir_all(parent_workspace.join(".opensymphony")) + .expect("parent metadata directory"); + let parent_envelope = serde_json::from_value(json!({ + "parent_issue_id": "issue-554", + "parent_identifier": "COE-554", + "run_id": "run-554", + "attempt": 1, + "hierarchy_generation": 4, + "workspace_path": parent_workspace, + "checkouts": { + "checkout-a": { + "repository_id": "github:repository:a", + "checkout_handle": "checkout-a", + "relative_path": "repositories/repo-a", + "target_branch": "develop", + "target_commit": "commit-a", + "instruction_path": "AGENTS.md", + "instruction_hash": "sha256:a" + }, + "checkout-b": { + "repository_id": "github:repository:b", + "checkout_handle": "checkout-b", + "relative_path": "repositories/repo-b", + "target_branch": "develop", + "target_commit": "commit-b", + "instruction_path": "AGENTS.md", + "instruction_hash": "sha256:b" + } + }, + "integration_instruction_path": "AGENTS.md", + "integration_instruction_hash": "sha256:parent", + "harness": "codex_app_server", + "model_profile": "default", + "requested_execution_scope": "parent_integration", + "effective_containment": "trusted_host", + "conversation_binding": "conversation-554" + })) + .expect("parent runtime envelope"); + let mut parent_run = run.clone(); + parent_run.run_id = "run-554".to_owned(); + parent_run.issue_id = "issue-554".to_owned(); + parent_run.identifier = "COE-554".to_owned(); + parent_run.workspace_path = parent_workspace.clone(); + parent_run.runtime_envelope = None; + parent_run.parent_runtime_envelope = Some(parent_envelope); + parent_run.attempt = 1; + std::fs::write( + parent_workspace.join(".opensymphony/run.json"), + serde_json::to_vec(&parent_run).expect("parent run manifest JSON"), + ) + .expect("parent run manifest"); + let mut parent_controller = + crate::opensymphony_orchestrator::ParentIntegrationController::new( + crate::opensymphony_domain::IssueId::new("issue-554").expect("parent id"), + 4, + ) + .expect("parent controller"); + parent_controller.state = + crate::opensymphony_orchestrator::ParentIntegrationState::Completed; + parent_controller.conversation_id = Some("conversation-554".to_owned()); + parent_controller.attempts = vec![ + crate::opensymphony_orchestrator::ParentVerificationAttempt { + id: "parent-attempt-1".to_owned(), + name: "final verification".to_owned(), + idempotency_key: "parent-run:554".to_owned(), + root: crate::opensymphony_orchestrator::ParentAttemptRoot::ParentRoot, + conversation_id: Some("conversation-554".to_owned()), + started_at: crate::opensymphony_domain::TimestampMs::new(1), + finished_at: Some(crate::opensymphony_domain::TimestampMs::new(2)), + timeout_ms: 1_000, + status: crate::opensymphony_orchestrator::ParentAttemptStatus::Passed, + commands: Vec::new(), + exit_code: Some(0), + bounded_log: String::new(), + log_truncated: false, + resources: Vec::new(), + cleanup: Some(crate::opensymphony_orchestrator::ParentCleanupReceipt { + status: crate::opensymphony_orchestrator::ParentCleanupStatus::Succeeded, + occurred_at: crate::opensymphony_domain::TimestampMs::new(2), + detail: None, + }), + input_version: "targets:554".to_owned(), + verified_repository_commits: BTreeMap::from([ + ( + crate::opensymphony_domain::CanonicalRepositoryId::new( + "github:repository:a", + ) + .expect("repo a"), + "commit-a".to_owned(), + ), + ( + crate::opensymphony_domain::CanonicalRepositoryId::new( + "github:repository:b", + ) + .expect("repo b"), + "commit-b".to_owned(), + ), + ]), + }, + ]; + parent_controller.final_evidence = + Some(crate::opensymphony_orchestrator::ParentFinalEvidence { + attempt_id: "parent-attempt-1".to_owned(), + conversation_id: "conversation-554".to_owned(), + input_version: "targets:554".to_owned(), + repository_commits: parent_controller.attempts[0] + .verified_repository_commits + .clone(), + recorded_at: crate::opensymphony_domain::TimestampMs::new(2), + }); + let parent_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + parent_integrations: BTreeMap::from([( + crate::opensymphony_domain::IssueId::new("issue-554").expect("parent id"), + parent_controller, + )]), + ..Default::default() + }; + std::fs::write( + workspace_root + .path() + .join(".opensymphony-orchestrator-state.json"), + serde_json::to_vec(&parent_state).expect("parent controller state"), + ) + .expect("parent controller state"); + let parent_bindings = + super::load_terminal_capture_bindings(workspace_root.path(), &["COE-554".to_owned()]) + .expect("parent capture binding"); + assert_eq!( + parent_bindings["coe-554"].repository_commits, + BTreeMap::from([ + ("github:repository:a".to_owned(), "commit-a".to_owned()), + ("github:repository:b".to_owned(), "commit-b".to_owned()) + ]) + ); + assert!(parent_bindings["coe-554"].parent_integration); + let mut source = SourceFile { + issues: vec![IssueEvidence { + identifier: "COE-554".to_owned(), + ..IssueEvidence::default() + }], + ..SourceFile::default() + }; + super::apply_terminal_capture_bindings(&mut source, &parent_bindings); + assert_eq!( + source.issues[0].execution_run_id.as_deref(), + Some("run-554") + ); + assert_eq!(source.issues[0].verified_repository_commits.len(), 2); + + let mut empty_parent_run = parent_run.clone(); + empty_parent_run + .parent_runtime_envelope + .as_mut() + .expect("parent envelope") + .checkouts + .clear(); + let mut empty_parent_state = parent_state.clone(); + let empty_controller = empty_parent_state + .parent_integrations + .get_mut(&crate::opensymphony_domain::IssueId::new("issue-554").expect("parent id")) + .expect("parent controller"); + empty_controller.attempts[0] + .verified_repository_commits + .clear(); + empty_controller + .final_evidence + .as_mut() + .expect("final evidence") + .repository_commits + .clear(); + std::fs::write( + parent_workspace.join(".opensymphony/run.json"), + serde_json::to_vec(&empty_parent_run).expect("empty parent run JSON"), + ) + .expect("empty parent run"); + std::fs::write( + workspace_root + .path() + .join(".opensymphony-orchestrator-state.json"), + serde_json::to_vec(&empty_parent_state).expect("empty parent state"), + ) + .expect("empty parent state"); + let empty_parent_bindings = + super::load_terminal_capture_bindings(workspace_root.path(), &["COE-554".to_owned()]) + .expect("empty parent capture binding"); + assert!(empty_parent_bindings["coe-554"].parent_integration); + assert!( + empty_parent_bindings["coe-554"] + .repository_commits + .is_empty() + ); + std::fs::write( + parent_workspace.join(".opensymphony/run.json"), + serde_json::to_vec(&parent_run).expect("restored parent run JSON"), + ) + .expect("restored parent run"); + std::fs::write( + workspace_root + .path() + .join(".opensymphony-orchestrator-state.json"), + serde_json::to_vec(&parent_state).expect("restored parent state"), + ) + .expect("restored parent state"); + + let mut incomplete_state = parent_state.clone(); + let incomplete_controller = incomplete_state + .parent_integrations + .get_mut(&crate::opensymphony_domain::IssueId::new("issue-554").expect("parent id")) + .expect("parent controller"); + incomplete_controller.state = + crate::opensymphony_orchestrator::ParentIntegrationState::Integrating; + incomplete_controller.final_evidence = None; + std::fs::write( + workspace_root + .path() + .join(".opensymphony-orchestrator-state.json"), + serde_json::to_vec(&incomplete_state).expect("incomplete parent controller state"), + ) + .expect("incomplete parent controller state"); + assert!( + super::load_terminal_capture_bindings(workspace_root.path(), &["COE-554".to_owned()]) + .expect("incomplete parent capture scan") + .is_empty(), + "tracker-terminal launch evidence must not bypass controller completion" + ); + std::fs::write( + workspace_root + .path() + .join(".opensymphony-orchestrator-state.json"), + serde_json::to_vec(&parent_state).expect("restored parent controller state"), + ) + .expect("restored parent controller state"); + + parent_run.status = RunStatus::Failed; + std::fs::write( + parent_workspace.join(".opensymphony/run.json"), + serde_json::to_vec(&parent_run).expect("failed parent run manifest JSON"), + ) + .expect("failed parent run manifest"); + assert!( + super::load_terminal_capture_bindings(workspace_root.path(), &["COE-554".to_owned()]) + .expect("failed parent capture scan") + .is_empty(), + "a failed parent run must not publish prepared commits" + ); + parent_run.status = RunStatus::Succeeded; + std::fs::write( + parent_workspace.join(".opensymphony/run.json"), + serde_json::to_vec(&parent_run).expect("restored parent run manifest JSON"), + ) + .expect("restored parent run manifest"); + let outside = workspace_root.path().join("outside"); std::fs::create_dir_all(&outside).expect("outside path"); let mut invalid_run: serde_json::Value = serde_json::from_slice( @@ -14230,6 +15268,7 @@ Public memory concept. ..SourceFile::default() }, &["COE-550".to_string()], + None, ) .expect_err("ambiguous repository scope must be rejected"); assert!(error.to_string().contains("multiple repository sources")); @@ -14270,6 +15309,7 @@ Public memory concept. identifiers: vec!["COE-551".to_string()], ..IssueSelection::default() }, + None, ) .expect_err("unmatched multi-repository capture must be rejected"); assert!( diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 4524fbcb2..36a2ae7d3 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -13,7 +13,7 @@ use std::{ use crate::opensymphony_cli::{ BlockedEnvironment, - memory::{MemoryScopeGrant, MemoryScopeGrantRegistry}, + memory::{MemoryLiveOverlayGrant, MemoryScopeGrant, MemoryScopeGrantRegistry}, }; use crate::opensymphony_codex::{ CODEX_APP_SERVER_CONTRACT, CODEX_APP_SERVER_KIND, CodexAppServerAdapter, @@ -24,27 +24,28 @@ use crate::opensymphony_codex::{ }; use crate::opensymphony_domain::{ CanonicalRepositoryId, ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, - IssueIdentifier, IssueState, IssueStateCategory, NormalizedIssue, RepositoryBindingOutcome, - RepositoryRouting, RetryEntry, RetryReason, RuntimeStreamState, TimestampMs, - TrackerErrorCategory, TrackerIssue, TrackerIssueSummary, WorkerOutcomeKind, + IssueIdentifier, IssueState, IssueStateCategory, NormalizedIssue, ParentVerificationEvidence, + RepositoryBindingOutcome, RepositoryRouting, RetryEntry, RetryReason, RuntimeStreamState, + TimestampMs, TrackerErrorCategory, TrackerIssue, TrackerIssueSummary, WorkerOutcomeKind, WorkerOutcomeRecord, WorkspaceKey, }; use crate::opensymphony_linear::{LinearClient, LinearConfig, LinearError, WorkpadComment}; use crate::opensymphony_openhands::{ - ConversationMoveOutcome, ConversationStoreKind, IssueConversationManifest, IssueSessionError, - IssueSessionObserver, IssueSessionPromptKind, IssueSessionResult, IssueSessionRunner, - IssueSessionRunnerConfig, LocalServerSupervisor, LocalServerTooling, MemoryWorkerAccess, - OPENHANDS_CONVERSATIONS_PATH_ENV, OpenHandsClient, OpenHandsConversationStorePaths, - OpenHandsError, SupervisedServerConfig, SupervisorConfig, TransportConfig, - WorkpadComment as SessionWorkpadComment, WorkpadCommentSource, build_continuation_guidance, - pending_conversation_manifest_path, superseded_conversation_manifests_path, + Conversation, ConversationMoveOutcome, ConversationStoreKind, IssueConversationManifest, + IssueSessionError, IssueSessionObserver, IssueSessionPromptKind, IssueSessionResult, + IssueSessionRunner, IssueSessionRunnerConfig, LocalServerSupervisor, LocalServerTooling, + MemoryWorkerAccess, OPENHANDS_CONVERSATIONS_PATH_ENV, OpenHandsClient, + OpenHandsConversationStorePaths, OpenHandsError, SupervisedServerConfig, SupervisorConfig, + TransportConfig, WorkpadComment as SessionWorkpadComment, WorkpadCommentSource, + build_continuation_guidance, pending_conversation_manifest_path, + superseded_conversation_manifests_path, }; use crate::opensymphony_orchestrator::{ ChildEligibilityEvidence, DurableOrchestratorState, HierarchySnapshot, LeaseResource, - ParentEligibilityEvidence, ProviderEvidenceBoundary, RecoveredRun, RecoveryRecord, - RequiredMergeCommit, RetryExhaustionRecord, RetryPendingRecord, TrackerBackend, + ParentEligibilityEvidence, ParentRepositoryTarget, ProviderEvidenceBoundary, RecoveredRun, + RecoveryRecord, RequiredMergeCommit, RetryExhaustionRecord, RetryPendingRecord, TrackerBackend, WorkerAbortReason, WorkerBackend, WorkerInterruptAcknowledgement, WorkerLaunch, - WorkerStartRequest, WorkerUpdate, WorkspaceBackend, + WorkerStartRequest, WorkerUpdate, WorkspaceBackend, parent_command_identity, }; use crate::opensymphony_workflow::{Environment, ProcessEnvironment, ResolvedWorkflow}; use crate::opensymphony_workspace::{ @@ -52,8 +53,9 @@ use crate::opensymphony_workspace::{ IssueLifecycleState, ParentCheckoutRequest, ParentRuntimeDescriptor, ParentRuntimeEnvelope, RunDescriptor, RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, WorkspaceHandle, WorkspaceManager, WorkspaceManagerConfig, - checkout_credential_environment_variables, compose_parent_prompt, compose_terminal_prompt, - environment_variable_names_equal, + checkout_credential_environment_variables, compose_parent_continuation_prompt, + compose_parent_prompt, compose_terminal_prompt, environment_variable_names_equal, + redact_runtime_diagnostic, }; use async_trait::async_trait; use thiserror::Error; @@ -66,6 +68,7 @@ use tokio::{ time::{timeout, timeout_at}, }; use url::Url; +use uuid::Uuid; use super::{ RunCommandError, RuntimeMemoryEnv, @@ -83,6 +86,185 @@ const CODEX_TERMINAL_TIMEOUT: Duration = Duration::from_secs(300); const CODEX_STDERR_TAIL_LINES: usize = 20; const CODEX_SCHEMA_STDERR_PREVIEW_CHARS: usize = 500; const OPENHANDS_AGENT_SERVER_KIND: &str = "openhands_agent_server"; +const PARENT_FINAL_VERIFICATION_PATH: &str = "evidence/final-verification.json"; +const MAX_PARENT_VERIFICATION_RECEIPT_BYTES: u64 = 64 * 1024; + +async fn parent_verification_receipt_path(workspace: &Path) -> Result { + let canonical_workspace = fs::canonicalize(workspace).await.map_err(|error| { + format!( + "failed to verify parent root {}: {error}", + workspace.display() + ) + })?; + let evidence_directory = workspace.join("evidence"); + let metadata = fs::symlink_metadata(&evidence_directory) + .await + .map_err(|error| { + format!( + "failed to verify parent evidence directory {}: {error}", + evidence_directory.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "parent evidence directory {} must be a real directory", + evidence_directory.display() + )); + } + let canonical_evidence = fs::canonicalize(&evidence_directory) + .await + .map_err(|error| { + format!( + "failed to resolve parent evidence directory {}: {error}", + evidence_directory.display() + ) + })?; + if !canonical_evidence.starts_with(&canonical_workspace) { + return Err("parent evidence directory escapes the verified parent root".to_owned()); + } + Ok(canonical_evidence.join( + Path::new(PARENT_FINAL_VERIFICATION_PATH) + .file_name() + .expect("verification receipt path has a file name"), + )) +} + +async fn clear_parent_verification_receipt(workspace: &Path) -> Result<(), String> { + let path = parent_verification_receipt_path(workspace).await?; + match fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to clear stale parent verification receipt {}: {error}", + path.display() + )), + } +} + +async fn load_parent_verification_receipt( + workspace: &Path, + envelope: &ParentRuntimeEnvelope, +) -> Result { + let path = parent_verification_receipt_path(workspace).await?; + let metadata = fs::symlink_metadata(&path) + .await + .map_err(|error| format!("parent harness did not write {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "parent verification receipt {} must be a regular file", + path.display() + )); + } + if metadata.len() > MAX_PARENT_VERIFICATION_RECEIPT_BYTES { + return Err(format!( + "parent verification receipt {} exceeds {} bytes", + path.display(), + MAX_PARENT_VERIFICATION_RECEIPT_BYTES + )); + } + let bytes = fs::read(&path).await.map_err(|error| { + format!( + "failed to read parent verification receipt {}: {error}", + path.display() + ) + })?; + let mut evidence: ParentVerificationEvidence = + serde_json::from_slice(&bytes).map_err(|error| { + format!( + "parent verification receipt {} is invalid JSON: {error}", + path.display() + ) + })?; + if evidence.run_id != envelope.run_id + || evidence.attempt != envelope.attempt + || evidence.hierarchy_generation != envelope.hierarchy_generation + { + return Err( + "parent verification receipt does not match the current run envelope".to_owned(), + ); + } + let expected_commits = envelope + .checkouts + .values() + .map(|checkout| { + CanonicalRepositoryId::new(checkout.repository_id.clone()) + .map(|repository_id| (repository_id, checkout.target_commit.clone())) + .map_err(|error| error.to_string()) + }) + .collect::, _>>()?; + if evidence.repository_commits != expected_commits { + return Err( + "parent verification receipt repository commits do not match the runtime envelope" + .to_owned(), + ); + } + if evidence.root != "parent_root" + && !envelope + .checkouts + .values() + .any(|checkout| checkout.checkout_handle == evidence.root) + { + return Err( + "parent verification command root is not a verified checkout handle".to_owned(), + ); + } + if evidence.command.trim().is_empty() { + return Err("parent verification command selector is empty".to_owned()); + } + evidence.command_hash = parent_command_identity(&evidence.command); + evidence.command = redact_runtime_diagnostic(&evidence.command); + Ok(evidence) +} + +async fn attach_parent_verification_receipt( + outcome: &mut WorkerOutcomeRecord, + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + issue: &NormalizedIssue, + envelope: Option<&ParentRuntimeEnvelope>, +) { + let Some(envelope) = envelope else { + return; + }; + let result = async { + let verified_parent = workspace_manager + .open_parent_execution_root_at(&issue_descriptor(issue), workspace.workspace_path()) + .await + .map_err(|error| { + format!( + "parent checkouts no longer match their exact verification targets: {error}" + ) + })?; + workspace_manager + .verify_parent_runtime_envelope(&verified_parent, envelope) + .map_err(|error| { + format!("parent runtime envelope changed before completion: {error}") + })?; + load_parent_verification_receipt(workspace.workspace_path(), envelope).await + } + .await; + apply_parent_verification_receipt_result(outcome, result); +} + +fn apply_parent_verification_receipt_result( + outcome: &mut WorkerOutcomeRecord, + result: Result, +) { + match result { + Ok(evidence) => outcome.parent_verification = Some(evidence), + Err(error) => { + if outcome.outcome == WorkerOutcomeKind::Succeeded { + outcome.outcome = WorkerOutcomeKind::Failed; + outcome.summary = + Some("parent final verification evidence was rejected".to_owned()); + } + outcome.error = Some(match outcome.error.take() { + Some(existing) => format!("{existing}; {error}"), + None => error, + }); + } + } +} #[derive(Debug, Error)] pub(super) enum CliWorkspaceError { @@ -90,6 +272,8 @@ pub(super) enum CliWorkspaceError { Workspace(#[from] WorkspaceError), #[error(transparent)] Identifier(#[from] crate::opensymphony_domain::IdentifierError), + #[error(transparent)] + RepositoryIdentity(#[from] crate::opensymphony_domain::RepositoryIdentityError), #[error("Codex lifecycle recovery failed: {0}")] CodexLifecycle(String), #[error("OpenHands lifecycle recovery failed: {0}")] @@ -3010,6 +3194,33 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { Ok(state.active_for(&resource)) } + async fn parent_workspace_targets( + &mut self, + issue: &NormalizedIssue, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + ) -> Result, Self::Error> { + if issue.sub_issues.is_empty() { + return Ok(Vec::new()); + } + let parent = self + .manager + .open_parent_execution_root_at_for_retry(&issue_descriptor(issue), &workspace.path) + .await?; + parent + .child_checkout_map + .repositories + .values() + .map(|checkout| { + Ok(ParentRepositoryTarget { + repository_id: CanonicalRepositoryId::new(checkout.repository_id.clone())?, + checkout_handle: checkout.checkout_handle.clone(), + relative_path: checkout.relative_path.clone(), + target_commit: checkout.target_commit.clone(), + }) + }) + .collect() + } + async fn recover_retry_exhaustion( &mut self, ) -> Result, Self::Error> { @@ -3840,6 +4051,7 @@ impl RuntimeWorkerBackend { fn spawn_worker_task(&mut self, request: WorkerStartRequest, recovered: bool) -> PendingLaunch { let issue = request.issue.clone(); let memory_grant_registry_recovered = request.memory_grant_registry_recovered; + let expected_parent_conversation_id = request.expected_parent_conversation_id.clone(); let mut runner_config = self.runner_config.clone(); let mut worker_env = self.worker_env.clone(); if let Some(memory) = runner_config.memory.as_mut() { @@ -4047,10 +4259,25 @@ impl RuntimeWorkerBackend { Ok(manifest) => manifest, Err(_) => return, }; + if let Some(error) = expected_parent_conversation_error( + expected_parent_conversation_id.as_deref(), + recovered_conversation + .as_ref() + .map(|manifest| manifest.conversation_id.as_str()), + ) { + report_launch_failure(&mut launch_tx, error); + return; + } let target_is_codex = route.harness_kind == CODEX_APP_SERVER_KIND; let switching_harness = recovered_conversation.as_ref().is_some_and(|manifest| { conversation_manifest_is_codex(manifest) != target_is_codex }); + if let Some(error) = + parent_harness_switch_error(parent_execution.is_some(), switching_harness) + { + report_launch_failure(&mut launch_tx, error); + return; + } let superseded_harness_manifest = switching_harness.then(|| { recovered_conversation .as_ref() @@ -4209,70 +4436,216 @@ impl RuntimeWorkerBackend { } else { None }; + let recovered_parent_memory_bearer = if memory_grant_registry_recovered + && parent_runtime_envelope.is_some() + && route.harness_kind == OPENHANDS_AGENT_SERVER_KIND + && memory_env + .as_ref() + .and_then(|memory| memory.scope_grants.as_ref()) + .is_some() + { + match recovered_conversation + .as_ref() + .filter(|_| !switching_harness) + { + Some(manifest) => match recover_parent_memory_bearer( + &client, + manifest, + memory_env + .as_ref() + .expect("memory environment checked above") + .endpoint + .as_str(), + ensured.handle.workspace_path(), + ) + .await + { + Ok(bearer) => Some(bearer), + Err(error) => { + report_launch_failure(&mut launch_tx, error); + return; + } + }, + None => None, + } + } else { + None + }; + let parent_authorized_work_items = if parent_execution.is_some() + && memory_env + .as_ref() + .is_some_and(|memory| memory.scope_grants.is_some()) + { + let mut work_items = issue + .sub_issues + .iter() + .flat_map(|child| [child.id.to_string(), child.identifier.to_string()]) + .collect::>(); + let state = match workspace_manager + .load_orchestrator_state::() + .await + { + Ok(Some(state)) => state, + Ok(None) => { + report_launch_failure( + &mut launch_tx, + "parent memory authorization requires durable hierarchy state", + ); + return; + } + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!( + "failed to load parent hierarchy for memory authorization: {error}" + ), + ); + return; + } + }; + if let Err(error) = state.validate() { + report_launch_failure( + &mut launch_tx, + format!( + "invalid durable parent hierarchy for memory authorization: {error}" + ), + ); + return; + } + work_items.extend(parent_descendant_work_items(&state, &issue.id)); + work_items + } else { + BTreeSet::new() + }; let mut memory_grant_requires_fresh_conversation = false; - let worker_memory_env = memory_env - .as_ref() - .filter(|_| parent_execution.is_none()) - .map(|memory| { - let mut scoped = memory.clone(); - scoped.project = worker_memory_project(&issue, &memory.project); - scoped.execution_repo = runtime_envelope + let mut memory_grant_error = None; + let worker_memory_env = memory_env.as_ref().map(|memory| { + let mut scoped = memory.clone(); + scoped.project = worker_memory_project(&issue, &memory.project); + scoped.parent_scope = parent_runtime_envelope.is_some(); + scoped.execution_repo = if scoped.parent_scope { + String::new() + } else { + runtime_envelope .as_ref() .map(|envelope| envelope.repository_binding.repository.id.to_string()) - .unwrap_or_else(|| memory.execution_repo.clone()); - scoped.run_id = runtime_envelope - .as_ref() - .map(|envelope| envelope.run_id.clone()); - scoped.attempt = runtime_envelope.as_ref().map(|envelope| envelope.attempt); - scoped.target_commit = runtime_envelope - .as_ref() - .map(|envelope| envelope.target_commit.clone()); - scoped.checkout_head = initially_verified_checkout + .unwrap_or_else(|| memory.execution_repo.clone()) + }; + scoped.run_id = runtime_envelope + .as_ref() + .map(|envelope| envelope.run_id.clone()) + .or_else(|| { + parent_runtime_envelope + .as_ref() + .map(|envelope| envelope.run_id.clone()) + }); + scoped.attempt = runtime_envelope + .as_ref() + .map(|envelope| envelope.attempt) + .or_else(|| { + parent_runtime_envelope + .as_ref() + .map(|envelope| envelope.attempt) + }); + scoped.target_commit = runtime_envelope + .as_ref() + .map(|envelope| envelope.target_commit.clone()); + scoped.checkout_head = initially_verified_checkout + .as_ref() + .map(|checkout| checkout.head.clone()); + let authorized_repositories = parent_runtime_envelope + .as_ref() + .map(parent_authorized_repositories) + .unwrap_or_else(|| { + scoped + .authorized_repositories_by_project + .get(&scoped.project) + .cloned() + .or_else(|| { + scoped + .authorized_repositories_by_project + .iter() + .find(|(project, _)| { + project.eq_ignore_ascii_case(&scoped.project) + }) + .map(|(_, repositories)| repositories.clone()) + }) + .filter(|repositories| !repositories.is_empty()) + .unwrap_or_else(|| BTreeSet::from([scoped.execution_repo.clone()])) + }); + scoped.authorized_repositories = authorized_repositories.clone(); + if let Some(grants) = &scoped.scope_grants { + let mut authorized_work_items = parent_authorized_work_items.clone(); + authorized_work_items.extend( + parent_execution + .as_ref() + .into_iter() + .flat_map(|parent| parent.child_checkout_map.repositories.values()) + .flat_map(|checkout| checkout.retained_checkouts.iter()) + .flat_map(|checkout| { + [checkout.issue_id.clone(), checkout.identifier.clone()] + }), + ); + let live_overlays = parent_runtime_envelope .as_ref() - .map(|checkout| checkout.head.clone()); - let authorized_repositories = scoped - .authorized_repositories_by_project - .get(&scoped.project) - .cloned() - .or_else(|| { - scoped - .authorized_repositories_by_project - .iter() - .find(|(project, _)| project.eq_ignore_ascii_case(&scoped.project)) - .map(|(_, repositories)| repositories.clone()) - }) - .filter(|repositories| !repositories.is_empty()) - .unwrap_or_else(|| BTreeSet::from([scoped.execution_repo.clone()])); - scoped.authorized_repositories = authorized_repositories.clone(); - if let Some(grants) = &scoped.scope_grants { - let (token, requires_fresh_conversation) = grants - .issue_or_refresh_with_claims(MemoryScopeGrant { - project: scoped.project.clone(), - project_set: scoped.project_set.clone(), - execution_repo: scoped.execution_repo.clone(), - authorized_repositories, - issue: issue.identifier.to_string(), - run_id: scoped.run_id.clone(), - attempt: scoped.attempt, - checkout_generation: runtime_envelope - .as_ref() - .map(|envelope| envelope.checkout_generation.clone()), - target_commit: scoped.target_commit.clone(), - checkout_head: scoped.checkout_head.clone(), - visibility: scoped.visibility, - capabilities: BTreeSet::new(), - }); + .map(parent_live_overlays) + .unwrap_or_default(); + let grant = MemoryScopeGrant { + project: scoped.project.clone(), + project_set: scoped.project_set.clone(), + execution_repo: scoped.execution_repo.clone(), + authorized_repositories, + authorized_work_items, + live_overlays, + issue: issue.identifier.to_string(), + run_id: scoped.run_id.clone(), + attempt: scoped.attempt, + checkout_generation: runtime_envelope + .as_ref() + .map(|envelope| envelope.checkout_generation.clone()), + target_commit: scoped.target_commit.clone(), + checkout_head: scoped.checkout_head.clone(), + visibility: scoped.visibility, + capabilities: BTreeSet::new(), + }; + let token = if scoped.parent_scope { + if let Some(bearer) = recovered_parent_memory_bearer.as_deref() { + match grants.restore_parent_claims(bearer, grant) { + Ok(token) => token, + Err(error) => { + memory_grant_error = Some(error); + String::new() + } + } + } else { + let (token, requires_fresh_conversation) = + grants.issue_or_refresh_parent_claims(grant); + memory_grant_requires_fresh_conversation = requires_fresh_conversation; + token + } + } else { + let (token, requires_fresh_conversation) = + grants.issue_or_refresh_with_claims(grant); memory_grant_requires_fresh_conversation = requires_fresh_conversation; - scoped.token = Some(token.clone()); - } - scoped.authorized_repositories_by_project.clear(); - scoped - }); + token + }; + scoped.token = Some(token.clone()); + } + scoped.authorized_repositories_by_project.clear(); + scoped + }); + if let Some(error) = memory_grant_error { + report_launch_failure( + &mut launch_tx, + format!("failed to restore parent memory authorization: {error}"), + ); + return; + } let memory_grant_requires_fresh_conversation = memory_grant_requires_fresh_conversation || (memory_grant_registry_recovered - && worker_memory_env - .as_ref() - .is_some_and(|memory| memory.scope_grants.is_some())); + && worker_memory_env.as_ref().is_some_and(|memory| { + memory.scope_grants.is_some() && !memory.parent_scope + })); let mut worker_environment = worker_env.clone(); if let Some(memory) = &worker_memory_env { inject_memory_env(&mut worker_environment, memory); @@ -4642,6 +5015,13 @@ impl RuntimeWorkerBackend { let parent_envelope = parent_runtime_envelope .as_ref() .expect("parent execution always creates a runtime envelope"); + if !recovered + && let Err(error) = + clear_parent_verification_receipt(ensured.handle.workspace_path()).await + { + report_launch_failure(&mut launch_tx, error); + return; + } terminal_prompt = Some(compose_parent_prompt( ¢ral_procedure, &format!( @@ -4660,7 +5040,11 @@ impl RuntimeWorkerBackend { &parent_repository_instructions, )); } + let continuation_prompt = parent_runtime_envelope + .as_ref() + .map(compose_parent_continuation_prompt); runner = runner.with_terminal_prompt(terminal_prompt.clone()); + runner = runner.with_continuation_prompt(continuation_prompt.clone()); if route.dry_run { if let Some(sender) = launch_tx.take() { @@ -4718,7 +5102,7 @@ impl RuntimeWorkerBackend { .as_ref() .and_then(|memory| memory.scope_grants.clone()) .filter(|_| memory_grant_requires_fresh_conversation); - let outcome = run_codex_stdio_issue_with_mode( + let mut outcome = run_codex_stdio_issue_with_mode( &route, &workspace_manager, &ensured.handle, @@ -4727,6 +5111,7 @@ impl RuntimeWorkerBackend { &run, &workflow, terminal_prompt.as_deref(), + continuation_prompt.as_deref(), &codex_bin, &codex_schema_validators, &codex_interrupts, @@ -4740,6 +5125,14 @@ impl RuntimeWorkerBackend { issue.identifier.as_str(), ) .await; + attach_parent_verification_receipt( + &mut outcome, + &workspace_manager, + &ensured.handle, + &issue, + parent_runtime_envelope.as_ref(), + ) + .await; if let Some(previous) = superseded_harness_manifest.as_ref() && let Err(error) = retire_replaced_harness_session_if_durable( &workspace_manager, @@ -4810,7 +5203,7 @@ impl RuntimeWorkerBackend { return; } - let outcome = match result { + let mut outcome = match result { Ok(result) => result.worker_outcome, Err(error) => WorkerOutcomeRecord::from_run( &run, @@ -4820,6 +5213,14 @@ impl RuntimeWorkerBackend { Some(error.to_string()), ), }; + attach_parent_verification_receipt( + &mut outcome, + &workspace_manager, + &ensured.handle, + &issue, + parent_runtime_envelope.as_ref(), + ) + .await; if let Some(previous) = superseded_harness_manifest.as_ref() && let Err(error) = retire_replaced_harness_session_if_durable( &workspace_manager, @@ -4908,6 +5309,132 @@ impl RuntimeWorkerBackend { } } +fn parent_authorized_repositories(envelope: &ParentRuntimeEnvelope) -> BTreeSet { + envelope + .checkouts + .values() + .map(|checkout| checkout.repository_id.clone()) + .collect() +} + +fn parent_descendant_work_items( + state: &DurableOrchestratorState, + parent_id: &IssueId, +) -> BTreeSet { + let mut work_items = BTreeSet::new(); + let mut visited = BTreeSet::from([parent_id.clone()]); + let mut pending = vec![parent_id.clone()]; + while let Some(issue_id) = pending.pop() { + let Some(snapshot) = state.hierarchy.get(&issue_id) else { + continue; + }; + for edge in snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + { + work_items.insert(edge.child_id.to_string()); + work_items.insert(edge.child_identifier.to_string()); + if visited.insert(edge.child_id.clone()) { + pending.push(edge.child_id.clone()); + } + } + } + work_items +} + +fn parent_live_overlays( + envelope: &ParentRuntimeEnvelope, +) -> BTreeMap { + envelope + .checkouts + .values() + .map(|checkout| { + ( + checkout.repository_id.clone(), + MemoryLiveOverlayGrant { + parent_workspace_path: envelope.workspace_path.clone(), + checkout_handle: checkout.checkout_handle.clone(), + relative_path: checkout.relative_path.clone(), + target_commit: checkout.target_commit.clone(), + }, + ) + }) + .collect() +} + +async fn recover_parent_memory_bearer( + client: &OpenHandsClient, + manifest: &IssueConversationManifest, + expected_endpoint: &str, + expected_workspace: &Path, +) -> Result { + let conversation_id = Uuid::parse_str(manifest.conversation_id.as_str()) + .map_err(|_| "persisted parent conversation id is invalid".to_owned())?; + let conversation = client + .get_conversation(conversation_id) + .await + .map_err(|_| "failed to inspect recovered parent conversation".to_owned())?; + let expected_workspace = fs::canonicalize(expected_workspace) + .await + .map_err(|error| format!("failed to verify parent workspace: {error}"))?; + let actual_workspace = fs::canonicalize(&conversation.workspace.working_dir) + .await + .map_err(|error| { + format!("failed to verify recovered parent conversation workspace: {error}") + })?; + if actual_workspace != expected_workspace { + return Err("recovered parent conversation is bound to another workspace".to_owned()); + } + parent_memory_bearer_from_conversation(&conversation, expected_endpoint) +} + +fn parent_memory_bearer_from_conversation( + conversation: &Conversation, + expected_endpoint: &str, +) -> Result { + let server = conversation + .agent + .mcp_config + .as_ref() + .and_then(|config| config.get("mcpServers")) + .and_then(serde_json::Value::as_object) + .and_then(|servers| servers.get("opensymphony-memory")) + .and_then(serde_json::Value::as_object) + .ok_or_else(|| { + "recovered parent conversation has no OpenSymphony memory configuration".to_owned() + })?; + let endpoint = server + .get("url") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + "recovered parent conversation has no OpenSymphony memory endpoint".to_owned() + })?; + if endpoint.trim_end_matches('/') != expected_endpoint.trim_end_matches('/') { + return Err( + "recovered parent conversation memory endpoint does not match this daemon".to_owned(), + ); + } + let authorization = server + .get("headers") + .and_then(serde_json::Value::as_object) + .and_then(|headers| { + headers.iter().find_map(|(name, value)| { + name.eq_ignore_ascii_case("authorization") + .then(|| value.as_str()) + .flatten() + }) + }) + .ok_or_else(|| { + "recovered parent conversation has no memory authorization bearer".to_owned() + })?; + authorization + .strip_prefix("Bearer ") + .filter(|token| token.starts_with("opensymphony-worker-")) + .map(str::to_owned) + .ok_or_else(|| "recovered parent conversation memory authorization is invalid".to_owned()) +} + fn annotate_route_decision( mut conversation: ConversationMetadata, worker_id: &str, @@ -5002,10 +5529,14 @@ fn inject_memory_env(env: &mut BTreeMap, memory: &RuntimeMemoryE project_set.clone(), ); } - env.insert( - "OPENSYMPHONY_MEMORY_EXECUTION_REPO".to_string(), - memory.execution_repo.clone(), - ); + if memory.parent_scope { + env.remove("OPENSYMPHONY_MEMORY_EXECUTION_REPO"); + } else { + env.insert( + "OPENSYMPHONY_MEMORY_EXECUTION_REPO".to_string(), + memory.execution_repo.clone(), + ); + } if let Some(token) = &memory.token { env.insert("OPENSYMPHONY_MEMORY_TOKEN".to_string(), token.clone()); } @@ -5032,7 +5563,14 @@ fn inject_memory_env(env: &mut BTreeMap, memory: &RuntimeMemoryE } fn memory_scope_prompt(memory: &RuntimeMemoryEnv) -> String { - let mut prompt = memory_scope_prompt_values(&memory.project, &memory.execution_repo); + let mut prompt = if memory.parent_scope { + format!( + "Memory scope is repository-neutral for project {} and requires an explicit repository for code access.", + memory.project + ) + } else { + memory_scope_prompt_values(&memory.project, &memory.execution_repo) + }; if let Some(project_set) = &memory.project_set { prompt.push_str(&format!(" Project set is {project_set}.")); } @@ -5117,9 +5655,9 @@ fn memory_access_from_runtime( authorized_repositories: memory.authorized_repositories.iter().cloned().collect(), run_id: memory.run_id.clone(), attempt: memory.attempt, - // A recovered supervised memory server has a newly reconstructed grant - // registry, so its bearer differs from the one stored in a reusable - // OpenHands conversation. In-process retries keep their conversation. + // Leaf recovery can rotate a process-local bearer. Parent recovery + // restores the persisted conversation bearer into this registry and + // keeps this false so the authoritative conversation is reattached. requires_fresh_conversation, project_set: memory.project_set.clone(), } @@ -5157,6 +5695,7 @@ async fn run_codex_stdio_issue( run, workflow, None, + None, codex_bin, codex_schema_validators, codex_interrupts, @@ -5182,6 +5721,7 @@ async fn run_codex_stdio_issue_with_mode( run: &crate::opensymphony_domain::RunAttempt, workflow: &ResolvedWorkflow, terminal_prompt: Option<&str>, + continuation_prompt: Option<&str>, codex_bin: &str, codex_schema_validators: &CodexSchemaValidatorCache, codex_interrupts: &CodexInterruptRegistry, @@ -5203,6 +5743,7 @@ async fn run_codex_stdio_issue_with_mode( run, workflow, terminal_prompt, + continuation_prompt, codex_bin, codex_schema_validators, codex_interrupts, @@ -5285,6 +5826,7 @@ async fn try_run_codex_stdio_issue( run: &crate::opensymphony_domain::RunAttempt, workflow: &ResolvedWorkflow, terminal_prompt: Option<&str>, + continuation_prompt: Option<&str>, codex_bin: &str, codex_schema_validators: &CodexSchemaValidatorCache, codex_interrupts: &CodexInterruptRegistry, @@ -5856,11 +6398,7 @@ async fn try_run_codex_stdio_issue( format!("failed to render workflow prompt for Codex route: {source}") })?, (IssueSessionPromptKind::Continuation, _) => { - let mut prompt = build_continuation_guidance(issue, run); - if let Some(scope) = memory_scope_prompt_from_environment(worker_env) { - prompt.push_str(&scope); - } - prompt + codex_continuation_prompt(issue, run, continuation_prompt, worker_env) } }; let turn_start = adapter @@ -6007,6 +6545,23 @@ async fn try_run_codex_stdio_issue( )) } +fn codex_continuation_prompt( + issue: &NormalizedIssue, + run: &crate::opensymphony_domain::RunAttempt, + continuation: Option<&str>, + worker_env: &BTreeMap, +) -> String { + let mut prompt = build_continuation_guidance(issue, run); + if let Some(continuation) = continuation { + prompt.push_str("\n\n"); + prompt.push_str(continuation); + } + if let Some(scope) = memory_scope_prompt_from_environment(worker_env) { + prompt.push_str(&scope); + } + prompt +} + fn scrub_checkout_credentials(command: &mut Command, checkout_credential_envs: &BTreeSet) { for variable in checkout_credential_envs { command.env_remove(variable); @@ -7471,6 +8026,29 @@ fn report_launch_failure( } } +fn parent_harness_switch_error(is_parent: bool, switching_harness: bool) -> Option { + (is_parent && switching_harness).then(|| { + "parent integration cannot switch harnesses while its authoritative conversation is bound" + .to_owned() + }) +} + +fn expected_parent_conversation_error( + expected: Option<&str>, + recovered: Option<&str>, +) -> Option { + let expected = expected?; + match recovered { + Some(actual) if actual == expected => None, + Some(actual) => Some(format!( + "parent integration requires conversation `{expected}`, but the persisted manifest binds `{actual}`" + )), + None => Some(format!( + "parent integration requires conversation `{expected}`, but its persisted manifest is missing" + )), + } +} + async fn read_verified_integration_instructions( instructions: Option<&ResolvedIntegrationInstructions>, ) -> Result, String> { @@ -7881,6 +8459,300 @@ mod tests { use super::*; + fn parent_envelope(workspace: &Path) -> ParentRuntimeEnvelope { + ParentRuntimeEnvelope { + parent_issue_id: "parent-id".to_owned(), + parent_identifier: "COE-PARENT".to_owned(), + run_id: "run-parent-1".to_owned(), + attempt: 1, + hierarchy_generation: 7, + workspace_path: workspace.to_path_buf(), + checkouts: BTreeMap::from([( + "checkout-one".to_owned(), + crate::opensymphony_workspace::ParentRuntimeCheckout { + repository_id: "github:repository:one".to_owned(), + checkout_handle: "checkout-one".to_owned(), + relative_path: PathBuf::from("repositories/one"), + target_branch: "develop".to_owned(), + target_commit: "abc123".to_owned(), + instruction_path: PathBuf::from("repositories/one/AGENTS.md"), + instruction_hash: "sha256:test".to_owned(), + }, + )]), + integration_instruction_path: None, + integration_instruction_hash: None, + harness: "codex_app_server".to_owned(), + model_profile: "default".to_owned(), + model: None, + requested_execution_scope: "parent_multi_checkout".to_owned(), + effective_containment: "trusted_host".to_owned(), + conversation_binding: None, + } + } + + fn parent_evidence() -> ParentVerificationEvidence { + ParentVerificationEvidence { + schema_version: 1, + run_id: "run-parent-1".to_owned(), + attempt: 1, + hierarchy_generation: 7, + repository_commits: BTreeMap::from([( + CanonicalRepositoryId::new("github:repository:one").expect("repository id"), + "abc123".to_owned(), + )]), + command: "cargo test".to_owned(), + command_hash: String::new(), + root: "parent_root".to_owned(), + } + } + + #[tokio::test] + async fn parent_verification_receipt_is_bound_to_exact_run_and_commits() { + let tempdir = TempDir::new().expect("tempdir"); + let evidence_dir = tempdir.path().join("evidence"); + fs::create_dir_all(&evidence_dir).expect("evidence directory"); + fs::write( + evidence_dir.join("final-verification.json"), + serde_json::to_vec(&parent_evidence()).expect("encode evidence"), + ) + .expect("write evidence"); + let envelope = parent_envelope(tempdir.path()); + let loaded = load_parent_verification_receipt(tempdir.path(), &envelope) + .await + .expect("valid receipt"); + assert_eq!(loaded.command, "cargo test"); + assert_eq!(loaded.command_hash, parent_command_identity("cargo test")); + + let mut stale = parent_evidence(); + stale.repository_commits.insert( + CanonicalRepositoryId::new("github:repository:one").expect("repository id"), + "different".to_owned(), + ); + fs::write( + evidence_dir.join("final-verification.json"), + serde_json::to_vec(&stale).expect("encode stale evidence"), + ) + .expect("write stale evidence"); + assert!( + load_parent_verification_receipt(tempdir.path(), &envelope) + .await + .expect_err("stale commit must be rejected") + .contains("repository commits") + ); + + let mut secret_command = parent_evidence(); + secret_command.command = "cargo test token=secret".to_owned(); + fs::write( + evidence_dir.join("final-verification.json"), + serde_json::to_vec(&secret_command).expect("encode secret command evidence"), + ) + .expect("write secret command evidence"); + let loaded_secret = load_parent_verification_receipt(tempdir.path(), &envelope) + .await + .expect("exact command identity is retained without persisting credentials"); + assert_ne!(loaded_secret.command, secret_command.command); + assert_eq!( + loaded_secret.command_hash, + parent_command_identity(&secret_command.command) + ); + } + + #[test] + fn parent_harness_switch_is_rejected_before_session_launch() { + assert!(parent_harness_switch_error(true, true).is_some()); + assert!(parent_harness_switch_error(true, false).is_none()); + assert!(parent_harness_switch_error(false, true).is_none()); + } + + #[test] + fn bound_parent_requires_its_manifest_before_session_launch() { + assert!( + expected_parent_conversation_error(Some("conversation-1"), None) + .is_some_and(|error| error.contains("manifest is missing")) + ); + assert!( + expected_parent_conversation_error(Some("conversation-1"), Some("conversation-2")) + .is_some_and(|error| error.contains("conversation-2")) + ); + assert_eq!( + expected_parent_conversation_error(Some("conversation-1"), Some("conversation-1")), + None + ); + assert_eq!(expected_parent_conversation_error(None, None), None); + } + + #[test] + fn codex_parent_retry_appends_current_receipt_instructions() { + let issue = sample_issue(); + let run = RunAttempt::new( + WorkerId::new("worker-parent-retry").expect("worker id"), + issue.id.clone(), + issue.identifier.clone(), + PathBuf::from("/parent"), + TimestampMs::new(1), + Some(RetryAttempt::new(1).expect("retry")), + 8, + ); + let prompt = codex_continuation_prompt( + &issue, + &run, + Some( + "run_id=run-worker-parent-retry attempt=2 receipt=evidence/final-verification.json", + ), + &BTreeMap::new(), + ); + + assert!(prompt.contains("Continue working on issue")); + assert!(prompt.contains("run_id=run-worker-parent-retry")); + assert!(prompt.contains("attempt=2")); + assert!(prompt.contains("evidence/final-verification.json")); + } + + #[test] + fn parent_memory_scope_uses_canonical_repository_ids_not_checkout_handles() { + let tempdir = TempDir::new().expect("tempdir"); + let envelope = parent_envelope(tempdir.path()); + + assert_eq!( + parent_authorized_repositories(&envelope), + BTreeSet::from(["github:repository:one".to_owned()]) + ); + let overlays = parent_live_overlays(&envelope); + assert_eq!( + overlays.keys().cloned().collect::>(), + BTreeSet::from(["github:repository:one".to_owned()]) + ); + assert_eq!( + overlays["github:repository:one"].checkout_handle, + "checkout-one" + ); + } + + #[test] + fn parent_memory_work_items_include_intermediate_descendants() { + fn child(id: &str, identifier: &str) -> crate::opensymphony_domain::TrackerIssueRef { + crate::opensymphony_domain::TrackerIssueRef { + id: id.to_owned(), + identifier: identifier.to_owned(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + } + } + + let mut root = sample_tracker_issue(&sample_issue()); + root.sub_issues = vec![child("parent-2", "COE-PARENT-2")]; + let mut intermediate = root.clone(); + intermediate.id = "parent-2".to_owned(); + intermediate.identifier = "COE-PARENT-2".to_owned(); + intermediate.sub_issues = vec![child("parent-3", "COE-PARENT-3")]; + let mut lower = root.clone(); + lower.id = "parent-3".to_owned(); + lower.identifier = "COE-PARENT-3".to_owned(); + lower.sub_issues = vec![child("leaf-4", "COE-LEAF-4")]; + let root_id = IssueId::new(root.id.clone()).expect("root id"); + let state = DurableOrchestratorState { + hierarchy: BTreeMap::from([ + (root_id.clone(), HierarchySnapshot::new(&root)), + ( + IssueId::new("parent-2").expect("parent 2 id"), + HierarchySnapshot::new(&intermediate), + ), + ( + IssueId::new("parent-3").expect("parent 3 id"), + HierarchySnapshot::new(&lower), + ), + ]), + ..Default::default() + }; + + assert_eq!( + parent_descendant_work_items(&state, &root_id), + BTreeSet::from([ + "parent-2".to_owned(), + "COE-PARENT-2".to_owned(), + "parent-3".to_owned(), + "COE-PARENT-3".to_owned(), + "leaf-4".to_owned(), + "COE-LEAF-4".to_owned(), + ]) + ); + } + + #[test] + fn recovered_parent_conversation_bearer_is_reused_without_rotation() { + let conversation: Conversation = serde_json::from_value(serde_json::json!({ + "conversation_id": Uuid::new_v4(), + "workspace": {"working_dir": "/parent", "kind": "LocalWorkspace"}, + "persistence_dir": "/parent/.openhands", + "max_iterations": 100, + "stuck_detection": true, + "execution_status": "idle", + "confirmation_policy": {"kind": "NeverConfirm"}, + "agent": { + "kind": "Agent", + "llm": {"model": "openai/test"}, + "mcp_config": { + "mcpServers": { + "opensymphony-memory": { + "url": "http://127.0.0.1:4812/", + "headers": { + "authorization": "Bearer opensymphony-worker-parent-before-restart" + } + } + } + } + } + })) + .expect("conversation"); + + assert_eq!( + parent_memory_bearer_from_conversation(&conversation, "http://127.0.0.1:4812") + .expect("recover bearer"), + "opensymphony-worker-parent-before-restart" + ); + assert!( + parent_memory_bearer_from_conversation(&conversation, "http://127.0.0.1:9999").is_err() + ); + } + + #[tokio::test] + async fn successful_parent_turn_without_receipt_becomes_failed() { + let tempdir = TempDir::new().expect("tempdir"); + let mut outcome = WorkerOutcomeRecord { + worker_id: WorkerId::new("parent-worker").expect("worker id"), + attempt: None, + outcome: WorkerOutcomeKind::Succeeded, + started_at: TimestampMs::new(10), + finished_at: TimestampMs::new(20), + turn_count: 1, + summary: Some("generic harness success".to_owned()), + error: None, + parent_verification: None, + }; + + apply_parent_verification_receipt_result( + &mut outcome, + Err(format!( + "parent harness did not write {}", + tempdir + .path() + .join(PARENT_FINAL_VERIFICATION_PATH) + .display() + )), + ); + + assert_eq!(outcome.outcome, WorkerOutcomeKind::Failed); + assert!( + outcome + .error + .as_deref() + .is_some_and(|error| error.contains("did not write")) + ); + assert!(outcome.parent_verification.is_none()); + } + #[tokio::test] async fn integration_instructions_are_revalidated_from_current_bytes_before_attach() { let tempdir = TempDir::new().expect("tempdir should exist"); @@ -9895,6 +10767,7 @@ mod tests { &run, &workflow, Some("COMPOSED TERMINAL PROMPT WITH CHECKOUT FACTS AND PINNED INSTRUCTIONS"), + None, fake_codex .to_str() .expect("fake codex path should be utf-8"), @@ -9991,6 +10864,7 @@ mod tests { &run, &workflow, None, + None, fake_codex .to_str() .expect("fake codex path should be utf-8"), @@ -10087,6 +10961,7 @@ mod tests { &run, &workflow, None, + None, fake_codex .to_str() .expect("fake codex path should be utf-8"), @@ -10188,6 +11063,7 @@ mod tests { &run, &workflow, None, + None, fake_codex .to_str() .expect("fake codex path should be utf-8"), @@ -10858,6 +11734,7 @@ mod tests { run, route: codex_test_route(true), memory_grant_registry_recovered: false, + expected_parent_conversation_id: None, }) .await .expect("dry-run worker should launch"); @@ -11018,6 +11895,7 @@ mod tests { run, route: codex_test_route(true), memory_grant_registry_recovered: false, + expected_parent_conversation_id: None, }) .await .expect("recovered dry-run worker should launch"); @@ -11157,6 +12035,7 @@ mod tests { token: Some("read-token".to_string()), project: "project-alpha".to_string(), execution_repo: "/tmp/project-alpha/services/api".to_string(), + parent_scope: false, authorized_repositories: BTreeSet::from(["repo-alpha".to_string()]), authorized_repositories_by_project: BTreeMap::new(), scope_grants: None, @@ -11276,6 +12155,7 @@ mod tests { user_override: false, }, memory_grant_registry_recovered: false, + expected_parent_conversation_id: None, }) .await .expect_err("workspace setup failure should fail the launch immediately"); diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index efee95267..2430ff94a 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -6,6 +6,7 @@ use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, fs::{self, File, OpenOptions}, io::{self, Write}, + net::SocketAddr, path::{Path, PathBuf}, process::ExitCode, sync::atomic::{AtomicU64, Ordering}, @@ -41,7 +42,7 @@ use crate::opensymphony_workflow::ProcessEnvironment; use crate::opensymphony_workspace::{WorkspaceError, checkout_credential_environment_variables}; use chrono::{DateTime, Utc}; use clap::Args; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use thiserror::Error; use tokio::{ @@ -66,6 +67,15 @@ use self::{ }, }; +const MEMORY_SERVER_BIND_STATE: &str = ".opensymphony-memory-bind.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct MemoryServerBindState { + schema_version: u32, + configured_ip: String, + selected_addr: String, +} + #[derive(Debug, Args, Clone)] pub struct RunArgs { #[arg(help = "Runtime config YAML path; defaults to ./config.yaml when present")] @@ -1078,6 +1088,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { target_commit: None, checkout_head: None, execution_repo: execution_repo.unwrap_or_else(|| runtime.target_repo.display().to_string()), + parent_scope: false, authorized_repositories: BTreeSet::from([runtime.target_repo.display().to_string()]), authorized_repositories_by_project: runtime .repository_routing @@ -1242,7 +1253,11 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { } }, }; - let mut auto_capture_completed_issues = terminal_issue_identifiers(&bootstrap_snapshot); + let startup_terminal_issues = terminal_issue_identifiers(&bootstrap_snapshot); + let mut auto_capture_completed_issues = initial_auto_capture_completed_issues( + &startup_terminal_issues, + runtime.memory.auto_capture, + ); push_recent_event( &mut recent_events, RecentEventKind::SnapshotPublished, @@ -1291,13 +1306,6 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { result = async { ticker.tick().await; let observed_at = now_timestamp(); - let capture_bindings_before_tick = if runtime.memory.auto_capture { - super::memory::load_all_terminal_capture_bindings( - &runtime.workflow.config.workspace.root, - ) - } else { - Ok(BTreeMap::new()) - }; let result = match apply_gateway_action_events( &mut scheduler, &gateway_journal, @@ -1307,9 +1315,9 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { Ok(()) => scheduler.tick(observed_at).await, Err(error) => Err(error), }; - (observed_at, capture_bindings_before_tick, result) + (observed_at, result) } => { - let (observed_at, capture_bindings_before_tick, result) = result; + let (observed_at, result) = result; match result { Ok(snapshot) => { let current_terminal_issues = terminal_issue_identifiers(&snapshot); @@ -1338,7 +1346,14 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { &recent_events, )).await; if !auto_capture_candidates.is_empty() { - let auto_capture_result = match capture_bindings_before_tick { + // Parent completion and its exact commit evidence + // can become durable in this scheduler tick. Load + // bindings afterward so capture does not use the + // pre-finalization controller snapshot. + let capture_bindings = super::memory::load_all_terminal_capture_bindings( + &runtime.workflow.config.workspace.root, + ); + let auto_capture_result = match capture_bindings { Ok(capture_bindings) => { super::memory::auto_capture_terminal( &runtime.target_repo, @@ -1607,6 +1622,7 @@ pub(super) struct RuntimeMemoryEnv { pub(super) target_commit: Option, pub(super) checkout_head: Option, pub(super) execution_repo: String, + pub(super) parent_scope: bool, pub(super) authorized_repositories: BTreeSet, pub(super) authorized_repositories_by_project: BTreeMap>, pub(super) scope_grants: Option, @@ -1619,9 +1635,14 @@ async fn start_runtime_memory_server( return Ok(None); }; let config = load_runtime_memory_config(runtime)?; - super::memory::start_memory_server_with_resolved_config( + let configured_bind = server.bind; + let selected_bind = resolve_runtime_memory_server_bind( + &runtime.workflow.config.workspace.root, + configured_bind, + )?; + let handle = super::memory::start_memory_server_with_resolved_config( config, - server.bind, + selected_bind, server.token.clone(), Some(runtime.workflow.config.workspace.root.clone()), runtime.config_path.clone(), @@ -1629,8 +1650,114 @@ async fn start_runtime_memory_server( Some(runtime.config_generation.clone()), ) .await - .map(Some) - .map_err(RunCommandError::MemoryServer) + .map_err(RunCommandError::MemoryServer)?; + if configured_bind.port() == 0 { + persist_runtime_memory_server_bind( + &runtime.workflow.config.workspace.root, + configured_bind, + handle.local_addr(), + )?; + } + Ok(Some(handle)) +} + +fn resolve_runtime_memory_server_bind( + workspace_root: &Path, + configured: SocketAddr, +) -> Result { + if configured.port() != 0 { + return Ok(configured); + } + let path = workspace_root.join(MEMORY_SERVER_BIND_STATE); + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(configured), + Err(error) => { + return Err(RunCommandError::MemoryServer( + crate::opensymphony_memory::MemoryError::InvalidInput(format!( + "failed to read persisted memory server bind {}: {error}", + path.display() + )), + )); + } + }; + let state: MemoryServerBindState = serde_json::from_str(&raw).map_err(|error| { + RunCommandError::MemoryServer(crate::opensymphony_memory::MemoryError::InvalidInput( + format!( + "failed to decode persisted memory server bind {}: {error}", + path.display() + ), + )) + })?; + let selected: SocketAddr = state.selected_addr.parse().map_err(|error| { + RunCommandError::MemoryServer(crate::opensymphony_memory::MemoryError::InvalidInput( + format!( + "persisted memory server bind {} is invalid: {error}", + path.display() + ), + )) + })?; + if state.schema_version != 1 + || state.configured_ip != configured.ip().to_string() + || selected.ip() != configured.ip() + || selected.port() == 0 + { + return Err(RunCommandError::MemoryServer( + crate::opensymphony_memory::MemoryError::InvalidInput(format!( + "persisted memory server bind {} does not match the configured interface", + path.display() + )), + )); + } + Ok(selected) +} + +fn persist_runtime_memory_server_bind( + workspace_root: &Path, + configured: SocketAddr, + selected: SocketAddr, +) -> Result<(), RunCommandError> { + fs::create_dir_all(workspace_root).map_err(|error| { + RunCommandError::MemoryServer(crate::opensymphony_memory::MemoryError::InvalidInput( + format!( + "failed to create workspace root {} for memory bind persistence: {error}", + workspace_root.display() + ), + )) + })?; + let path = workspace_root.join(MEMORY_SERVER_BIND_STATE); + let state = MemoryServerBindState { + schema_version: 1, + configured_ip: configured.ip().to_string(), + selected_addr: selected.to_string(), + }; + let payload = serde_json::to_vec_pretty(&state).map_err(|error| { + RunCommandError::MemoryServer(crate::opensymphony_memory::MemoryError::InvalidInput( + format!("failed to encode persisted memory server bind: {error}"), + )) + })?; + let temporary = workspace_root.join(format!( + "{MEMORY_SERVER_BIND_STATE}.{}.tmp", + Uuid::new_v4().simple() + )); + fs::write(&temporary, payload).map_err(|error| { + RunCommandError::MemoryServer(crate::opensymphony_memory::MemoryError::InvalidInput( + format!( + "failed to write persisted memory server bind {}: {error}", + temporary.display() + ), + )) + })?; + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(RunCommandError::MemoryServer( + crate::opensymphony_memory::MemoryError::InvalidInput(format!( + "failed to publish persisted memory server bind {}: {error}", + path.display() + )), + )); + } + Ok(()) } fn load_runtime_memory_config( @@ -1871,6 +1998,17 @@ fn auto_capture_candidates( .collect() } +fn initial_auto_capture_completed_issues( + startup_terminal_issues: &BTreeSet, + auto_capture_enabled: bool, +) -> BTreeSet { + if auto_capture_enabled { + BTreeSet::new() + } else { + startup_terminal_issues.clone() + } +} + fn mark_auto_capture_completed( completed_issues: &mut BTreeSet, candidates: &[String], @@ -1908,6 +2046,32 @@ mod tests { keys.iter().map(|key| key.to_string()).collect() } + #[test] + fn ephemeral_memory_bind_reuses_its_persisted_port_after_restart() { + let root = tempfile::tempdir().expect("workspace root"); + let configured: SocketAddr = "127.0.0.1:0".parse().expect("configured bind"); + let selected: SocketAddr = "127.0.0.1:48123".parse().expect("selected bind"); + + assert_eq!( + resolve_runtime_memory_server_bind(root.path(), configured).expect("initial bind"), + configured + ); + persist_runtime_memory_server_bind(root.path(), configured, selected) + .expect("persist selected bind"); + assert_eq!( + resolve_runtime_memory_server_bind(root.path(), configured).expect("recovered bind"), + selected + ); + assert!( + resolve_runtime_memory_server_bind( + root.path(), + "0.0.0.0:0".parse().expect("changed interface") + ) + .is_err(), + "a changed interface must not silently rotate a bound parent endpoint" + ); + } + #[test] fn gateway_replan_outcome_records_completed_or_rejected_result() { let dispatch = EventRecord::builder() @@ -2353,6 +2517,16 @@ mod tests { assert_eq!(retry_candidates, vec!["COE-2".to_string()]); } + #[test] + fn startup_terminal_issues_retry_capture_after_daemon_restart() { + let terminal = issue_set(&["COE-1", "COE-2"]); + assert!(initial_auto_capture_completed_issues(&terminal, true).is_empty()); + assert_eq!( + initial_auto_capture_completed_issues(&terminal, false), + terminal + ); + } + #[test] fn auto_capture_candidates_forget_reopened_issues() { let current = issue_set(&["COE-2"]); diff --git a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs index 51ae90096..cd8b79395 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs @@ -974,6 +974,7 @@ tracker: turn_count: 1, summary: None, error: Some("historical failure".to_owned()), + parent_verification: None, }); let issue = map_single_issue(domain_issue); assert_eq!( @@ -1003,6 +1004,7 @@ tracker: turn_count: 1, summary: None, error: None, + parent_verification: None, }); assert_eq!(map_single_issue(domain_issue).retry_count, 3); @@ -1042,6 +1044,7 @@ tracker: turn_count: 1, summary: None, error: Some("historical failure".to_owned()), + parent_verification: None, }); assert_eq!( diff --git a/crates/opensymphony-domain/src/lib.rs b/crates/opensymphony-domain/src/lib.rs index 456555e6f..081ee7f88 100644 --- a/crates/opensymphony-domain/src/lib.rs +++ b/crates/opensymphony-domain/src/lib.rs @@ -37,9 +37,10 @@ pub use runtime::{ ConversationActivityEvent, ConversationMetadata, DetachMetadata, DetachReason, HarnessInterruptCommand, HarnessInterruptExpectedNextState, HarnessInterruptReason, HarnessInterruptState, HarnessInterruptStatus, HistorySyncStatus, LivenessState, - ReconnectStatus, ReleaseReason, RetryAttempt, RetryCalculationError, RetryEntry, RetryPolicy, - RetryReason, RunAttempt, RuntimeLivenessPhase, RuntimeProgressSnapshot, RuntimeStreamState, - StallMetadata, StreamHealth, WorkerOutcomeKind, WorkerOutcomeRecord, WorkspaceRecord, + ParentVerificationEvidence, ReconnectStatus, ReleaseReason, RetryAttempt, + RetryCalculationError, RetryEntry, RetryPolicy, RetryReason, RunAttempt, RuntimeLivenessPhase, + RuntimeProgressSnapshot, RuntimeStreamState, StallMetadata, StreamHealth, WorkerOutcomeKind, + WorkerOutcomeRecord, WorkspaceRecord, }; pub use snapshot::{ ComponentHealthSnapshot, DaemonSnapshot, HealthStatus, HierarchyStateSnapshot, IssueSnapshot, @@ -804,6 +805,7 @@ mod tests { turn_count: 0, summary: None, error: Some("boom".to_owned()), + parent_verification: None, }; let retry = must(RetryEntry::failure( &issue, @@ -1311,6 +1313,7 @@ mod tests { turn_count: 0, summary: Some("stale worker".to_owned()), error: Some("boom".to_owned()), + parent_verification: None, }; let retry = must(RetryEntry::failure( &issue, @@ -1365,6 +1368,7 @@ mod tests { turn_count: 1, summary: Some("old attempt".to_owned()), error: Some("boom".to_owned()), + parent_verification: None, }; let retry = must(RetryEntry::failure( &issue, diff --git a/crates/opensymphony-domain/src/runtime.rs b/crates/opensymphony-domain/src/runtime.rs index 3948f6fd9..71ccb7cb9 100644 --- a/crates/opensymphony-domain/src/runtime.rs +++ b/crates/opensymphony-domain/src/runtime.rs @@ -1,12 +1,12 @@ -use std::{fmt, num::NonZeroU32, path::PathBuf}; +use std::{collections::BTreeMap, fmt, num::NonZeroU32, path::PathBuf}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; use super::{ - ConversationId, DurationMs, IssueId, IssueIdentifier, RepositoryBinding, TimestampMs, WorkerId, - WorkspaceKey, + CanonicalRepositoryId, ConversationId, DurationMs, IssueId, IssueIdentifier, RepositoryBinding, + TimestampMs, WorkerId, WorkspaceKey, }; /// Normalized liveness phase for a long-running OpenSymphony execution turn. @@ -1062,6 +1062,25 @@ impl HarnessInterruptState { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentVerificationEvidence { + pub schema_version: u32, + pub run_id: String, + pub attempt: u32, + pub hierarchy_generation: u64, + pub repository_commits: BTreeMap, + /// Exact command text in the worker-authored receipt. The trusted loader + /// replaces this with a redacted diagnostic before the evidence enters + /// durable orchestrator state. + pub command: String, + /// Non-reversible identity computed by the trusted loader from the exact + /// receipt command. Worker-authored values are ignored and overwritten. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub command_hash: String, + /// `parent_root` or one checkout handle from the verified parent envelope. + pub root: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerOutcomeRecord { pub worker_id: WorkerId, @@ -1072,6 +1091,8 @@ pub struct WorkerOutcomeRecord { pub turn_count: u32, pub summary: Option, pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_verification: Option, } impl WorkerOutcomeRecord { @@ -1091,6 +1112,7 @@ impl WorkerOutcomeRecord { turn_count: run.turn_count, summary, error, + parent_verification: None, } } } diff --git a/crates/opensymphony-memory/src/capture_render.rs b/crates/opensymphony-memory/src/capture_render.rs index 66dbda041..4ffd0d856 100644 --- a/crates/opensymphony-memory/src/capture_render.rs +++ b/crates/opensymphony-memory/src/capture_render.rs @@ -606,6 +606,7 @@ fn render_issue_capsule( .filter_map(|pr| pr.merge_sha.clone()) .collect(), terminal_runtime: terminal_runtime_source_ref(&plan.issue), + parent_terminal_runtime: parent_terminal_runtime_source_refs(&plan.issue), }, timestamp: plan .issue @@ -769,6 +770,8 @@ struct SourceRefs { github_merge_shas: Vec, #[serde(skip_serializing_if = "Option::is_none")] terminal_runtime: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + parent_terminal_runtime: Vec, } fn terminal_runtime_source_ref(issue: &IssueEvidence) -> Option { @@ -784,6 +787,31 @@ fn terminal_runtime_source_ref(issue: &IssueEvidence) -> Option { )) } +fn parent_terminal_runtime_source_refs(issue: &IssueEvidence) -> Vec { + if !issue.parent_integration { + return Vec::new(); + } + let Some(run_id) = issue.execution_run_id.as_deref() else { + return Vec::new(); + }; + if issue.verified_repository_commits.is_empty() { + return vec![format!( + "run={run_id};attempt={};repo=;target_commit=", + issue.execution_attempt.unwrap_or_default() + )]; + } + issue + .verified_repository_commits + .iter() + .map(|(repository_id, commit)| { + format!( + "run={run_id};attempt={};repo={repository_id};target_commit={commit}", + issue.execution_attempt.unwrap_or_default() + ) + }) + .collect() +} + #[derive(Debug, Serialize)] struct DocsSyncFrontmatter { status: String, diff --git a/crates/opensymphony-memory/src/index.rs b/crates/opensymphony-memory/src/index.rs index 7f03e23ef..2eb4e58b6 100644 --- a/crates/opensymphony-memory/src/index.rs +++ b/crates/opensymphony-memory/src/index.rs @@ -187,6 +187,37 @@ fn index_capture_plan(config: &MemoryConfig, plan: &CapturePlan) -> Result<(), M registration_source_id: None, }); } + if issue_plan.issue.parent_integration + && let Some(run_id) = issue_plan.issue.execution_run_id.as_deref() + { + if issue_plan.issue.verified_repository_commits.is_empty() { + live_source_refs.push(MemorySourceRef { + kind: "parent_terminal_runtime_envelope".to_owned(), + id: format!( + "run={run_id};attempt={};repo=;target_commit=", + issue_plan.issue.execution_attempt.unwrap_or_default() + ), + url: None, + repo_id: None, + symbol_key: None, + registration_source_id: None, + }); + } else { + live_source_refs.extend(issue_plan.issue.verified_repository_commits.iter().map( + |(repository_id, commit)| MemorySourceRef { + kind: "parent_terminal_runtime_envelope".to_owned(), + id: format!( + "run={run_id};attempt={};repo={repository_id};target_commit={commit}", + issue_plan.issue.execution_attempt.unwrap_or_default() + ), + url: None, + repo_id: Some(repository_id.clone()), + symbol_key: None, + registration_source_id: None, + }, + )); + } + } for source_ref in live_source_refs { if !source_refs.contains(&source_ref) { source_refs.push(source_ref); @@ -558,29 +589,36 @@ fn capture_scope_refs(config: &MemoryConfig, plan: &CaptureIssuePlan) -> Vec>(); - let routed_repository_id = plan.issue.repository_id.clone().or_else(|| { - let candidates = config - .repository_sources - .values() - .filter(|source| { - !issue_projects.is_empty() - && source - .project_scope_ids - .iter() - .any(|project| issue_projects.contains(project)) + let routed_repository_id = if plan.issue.parent_integration { + None + } else { + plan.issue + .repository_id + .clone() + .or_else(|| { + let candidates = config + .repository_sources + .values() + .filter(|source| { + !issue_projects.is_empty() + && source + .project_scope_ids + .iter() + .any(|project| issue_projects.contains(project)) + }) + .map(|source| source.repository_id.clone()) + .collect::>(); + (candidates.len() == 1) + .then(|| candidates.into_iter().next()) + .flatten() + .or_else(|| config.default_repository_id.clone()) }) - .map(|source| source.repository_id.clone()) - .collect::>(); - (candidates.len() == 1) - .then(|| candidates.into_iter().next()) - .flatten() - .or_else(|| config.default_repository_id.clone()) - }) - .or_else(|| { - (config.repository_sources.len() == 1) - .then(|| config.repository_sources.keys().next().cloned()) - .flatten() - }); + .or_else(|| { + (config.repository_sources.len() == 1) + .then(|| config.repository_sources.keys().next().cloned()) + .flatten() + }) + }; if let Some(repository_id) = routed_repository_id.as_deref() && !refs .iter() @@ -602,6 +640,20 @@ fn capture_scope_refs(config: &MemoryConfig, plan: &CaptureIssuePlan) -> Vec, + /// Internal marker that prevents a repository-neutral parent capture from + /// inheriting leaf ownership through project or default-repository hints. + #[serde(skip)] + pub parent_integration: bool, + #[serde(default)] + pub verified_repository_commits: BTreeMap, #[serde(default)] pub execution_run_id: Option, #[serde(default)] @@ -1062,6 +1068,11 @@ pub struct MemoryScopeFilter { /// manufacture or widen its repository set through query arguments. #[serde(skip)] pub authorized_repositories: Option>, + /// Work-item authorization supplied by a worker grant. Like repository + /// authorization, this is an internal upper bound rather than a caller + /// controlled query field. + #[serde(skip)] + pub authorized_work_items: Option>, /// `public` narrows a worker read to public records. A private grant is /// allowed to read both private and public records. #[serde(skip)] @@ -3379,6 +3390,11 @@ Reviews are triggered when you open a pull request for review. id: "repository-1".to_string(), label: Some("Repo One".to_string()), }, + KnowledgeScope { + kind: KnowledgeScopeKind::WorkItem, + id: "COE-CHILD".to_string(), + label: Some("Child work item".to_string()), + }, ], source_scope_refs: BTreeMap::new(), source_refs: Vec::new(), @@ -3411,6 +3427,66 @@ Reviews are triggered when you open a pull request for review. ..MemoryScopeFilter::default() } )); + let mut multi_repository_issue = issue.clone(); + multi_repository_issue.scope_refs.push(KnowledgeScope { + kind: KnowledgeScopeKind::Repository, + id: "repository-2@run-17".to_string(), + label: Some("Repo Two exact run".to_string()), + }); + assert!(!indexed_issue_matches_scope( + &config, + &multi_repository_issue, + &MemoryScopeFilter { + authorized_repositories: Some(BTreeSet::from(["repository-1".to_string()])), + ..MemoryScopeFilter::default() + } + )); + assert!(indexed_issue_matches_scope( + &config, + &multi_repository_issue, + &MemoryScopeFilter { + authorized_repositories: Some(BTreeSet::from([ + "repository-1".to_string(), + "repository-2".to_string(), + ])), + ..MemoryScopeFilter::default() + } + )); + assert!(!indexed_issue_matches_scope( + &config, + &issue, + &MemoryScopeFilter { + authorized_work_items: Some(BTreeSet::from(["coe-123".to_string()])), + ..MemoryScopeFilter::default() + } + )); + assert!(!indexed_issue_matches_scope( + &config, + &issue, + &MemoryScopeFilter { + authorized_work_items: Some(BTreeSet::from(["coe-child".to_string()])), + ..MemoryScopeFilter::default() + } + )); + assert!(indexed_issue_matches_scope( + &config, + &issue, + &MemoryScopeFilter { + authorized_work_items: Some(BTreeSet::from([ + "coe-123".to_string(), + "coe-child".to_string(), + ])), + ..MemoryScopeFilter::default() + } + )); + assert!(!indexed_issue_matches_scope( + &config, + &issue, + &MemoryScopeFilter { + authorized_work_items: Some(BTreeSet::from(["COE-OUTSIDER".to_string()])), + ..MemoryScopeFilter::default() + } + )); assert!(!indexed_issue_matches_scope( &config, &issue, @@ -3660,6 +3736,49 @@ Reviews are triggered when you open a pull request for review. .issue_count, 1 ); + let authorized_work_item = MemoryScopeFilter { + authorized_work_items: Some(BTreeSet::from(["COE-123".to_string()])), + ..scoped.clone() + }; + let unrelated_work_item = MemoryScopeFilter { + authorized_work_items: Some(BTreeSet::from(["COE-999".to_string()])), + ..scoped.clone() + }; + assert_eq!( + search_with_scope(&config, "websocket", 10, &authorized_work_item) + .expect("authorized work-item search") + .len(), + 1 + ); + assert_eq!( + search_with_scope(&config, "websocket", 10, &unrelated_work_item) + .expect("unrelated work-item search") + .len(), + 0 + ); + assert_eq!( + related_by_area_with_scope(&config, "openhands-runtime", 10, &unrelated_work_item,) + .expect("unrelated work-item area lookup") + .len(), + 0 + ); + assert_eq!( + related_by_paths_with_scope( + &config, + &[PathBuf::from("crates/opensymphony-openhands/src/client.rs")], + 10, + &unrelated_work_item, + ) + .expect("unrelated work-item path lookup") + .len(), + 0 + ); + assert_eq!( + status_with_scope(&config, &IssueSelection::default(), &unrelated_work_item,) + .expect("unrelated work-item status") + .issue_count, + 0 + ); assert_eq!( search_with_scope( &config, @@ -3747,11 +3866,21 @@ Reviews are triggered when you open a pull request for review. labels: vec!["runtime".to_string()], ..IssueEvidence::default() }); + source.issues.push(IssueEvidence { + identifier: "COE-125".to_string(), + title: "Same repository runtime record".to_string(), + labels: vec!["runtime".to_string()], + ..IssueEvidence::default() + }); let plan = plan_capture( &config, &source, &IssueSelection { - identifiers: vec!["COE-123".to_string(), "COE-124".to_string()], + identifiers: vec![ + "COE-123".to_string(), + "COE-124".to_string(), + "COE-125".to_string(), + ], ..IssueSelection::default() }, true, @@ -3799,6 +3928,12 @@ Reviews are triggered when you open a pull request for review. duckdb::params![repo_b_scopes, "COE-124"], ) .expect("repo-b scope refs"); + connection + .execute( + "UPDATE issues SET scope_refs_json = ? WHERE issue_key = ?", + duckdb::params![repo_a_scopes, "COE-125"], + ) + .expect("same-doc repo-a scope refs"); connection .execute( "INSERT INTO issue_areas (issue_key, area, source_id) VALUES (?, ?, ?)", @@ -3827,6 +3962,21 @@ Reviews are triggered when you open a pull request for review. .expect("repository-local docs should ignore other repository records"); assert!(docs.contains("# OpenHands Runtime")); + let docs_error = docs_for_area_with_scope( + &selected_config, + "openhands-runtime", + &MemoryScopeFilter { + repo: Some("repo-a".to_string()), + authorized_work_items: Some(BTreeSet::from(["COE-123".to_string()])), + ..MemoryScopeFilter::default() + }, + ) + .expect_err("aggregate docs must reject unauthorized same-document contributors"); + assert!( + docs_error + .to_string() + .contains("outside the requested scope") + ); let search = search_with_scope( &selected_config, "WebSocket", diff --git a/crates/opensymphony-memory/src/query.rs b/crates/opensymphony-memory/src/query.rs index 436522113..15b0f04fe 100644 --- a/crates/opensymphony-memory/src/query.rs +++ b/crates/opensymphony-memory/src/query.rs @@ -422,11 +422,20 @@ pub fn docs_for_area_with_scope( if scoped.repo.is_none() { scoped.repo = selected_repository.clone(); } + // A topic document is a materialized aggregate. Determine every row + // that contributes to this repository's copy before applying the + // caller's project or authorization bounds; otherwise one authorized + // row could be used as an existence gate for a document that also + // contains unauthorized rows. + let document_scope = MemoryScopeFilter { + repo: scoped.repo.clone(), + ..MemoryScopeFilter::default() + }; let area_issues = issues .iter() .filter(|issue| { issue - .areas_for_scope(config, &scoped) + .areas_for_scope(config, &document_scope) .iter() .any(|candidate| candidate == &area.slug) }) @@ -438,8 +447,10 @@ pub fn docs_for_area_with_scope( .iter() .copied() .filter(|issue| { - let mut repository_scope = scoped.clone(); - repository_scope.repo = Some(repository_id.to_string()); + let repository_scope = MemoryScopeFilter { + repo: Some(repository_id.to_string()), + ..MemoryScopeFilter::default() + }; indexed_issue_matches_scope(config, issue, &repository_scope) }) .collect::>() @@ -454,13 +465,16 @@ pub fn docs_for_area_with_scope( area.slug ))); } - if (scope.project.is_some() || scope.project_set.is_some()) + if (scope.project.is_some() + || scope.project_set.is_some() + || scope.authorized_repositories.is_some() + || scope.authorized_work_items.is_some()) && scoped_area_issues .iter() .any(|issue| !indexed_issue_matches_scope(config, issue, &scoped)) { return Err(MemoryError::InvalidInput(format!( - "topic doc for area `{}` contains memory outside the requested project scope", + "topic doc for area `{}` contains memory outside the requested scope", area.slug ))); } @@ -474,6 +488,8 @@ fn docs_scope_requires_index_check(scope: &MemoryScopeFilter) -> bool { || scope.issue.is_some() || scope.milestone.is_some() || scope.repo.is_some() + || scope.authorized_repositories.is_some() + || scope.authorized_work_items.is_some() } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1011,6 +1027,11 @@ fn indexed_issue_matches_scope( { return false; } + if let Some(authorized_work_items) = &scope.authorized_work_items + && !indexed_issue_matches_authorized_work_items(issue, authorized_work_items) + { + return false; + } if let Some(issue_key) = scope.issue.as_ref().map(|issue| normalize_issue_key(issue)) && issue.issue_key != issue_key { @@ -1068,6 +1089,28 @@ fn indexed_issue_matches_scope( true } +fn indexed_issue_matches_authorized_work_items( + issue: &IndexedIssue, + authorized_work_items: &BTreeSet, +) -> bool { + if authorized_work_items.is_empty() { + return false; + } + std::iter::once(issue.issue_key.as_str()) + .chain( + issue + .scope_refs + .iter() + .filter(|scope| scope.kind == KnowledgeScopeKind::WorkItem) + .map(|scope| scope.id.as_str()), + ) + .all(|work_item| { + authorized_work_items + .iter() + .any(|authorized| authorized.eq_ignore_ascii_case(work_item)) + }) +} + fn indexed_issue_matches_authorized_repositories( issue: &IndexedIssue, authorized_repositories: &BTreeSet, @@ -1088,9 +1131,12 @@ fn indexed_issue_matches_authorized_repositories( .map(|scope| scope.id.as_str()), ); !repositories.is_empty() - && repositories - .iter() - .any(|repository| authorized_repositories.contains(*repository)) + && repositories.iter().all(|repository| { + authorized_repositories.contains(*repository) + || repository + .rsplit_once('@') + .is_some_and(|(base, _)| authorized_repositories.contains(base)) + }) } fn indexed_issue_matches_project( diff --git a/crates/opensymphony-openhands/src/session.rs b/crates/opensymphony-openhands/src/session.rs index 7e94b1ec5..9bf560152 100644 --- a/crates/opensymphony-openhands/src/session.rs +++ b/crates/opensymphony-openhands/src/session.rs @@ -89,6 +89,7 @@ pub struct IssueSessionRunnerConfig { pub memory: Option, pub repository_instructions: Option, pub terminal_prompt: Option, + pub continuation_prompt: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -101,10 +102,9 @@ pub struct MemoryWorkerAccess { pub authorized_repositories: Vec, pub run_id: Option, pub attempt: Option, - /// The token is issued by this process's memory server. A recovered - /// supervised server may require a replacement conversation because its - /// reconstructed grant registry cannot update the bearer stored by the - /// existing server-side MCP configuration. + /// The token is issued by this process's memory server. A recovered leaf + /// run may require a replacement conversation when its bearer rotated; + /// parent recovery restores the persisted bearer and keeps this false. pub requires_fresh_conversation: bool, } @@ -359,6 +359,7 @@ impl Default for IssueSessionRunnerConfig { memory: None, repository_instructions: None, terminal_prompt: None, + continuation_prompt: None, } } } @@ -385,6 +386,7 @@ impl IssueSessionRunnerConfig { memory: None, repository_instructions: None, terminal_prompt: None, + continuation_prompt: None, } } @@ -1314,6 +1316,11 @@ impl IssueSessionRunner { self } + pub fn with_continuation_prompt(mut self, prompt: Option) -> Self { + self.config.continuation_prompt = prompt; + self + } + pub fn client(&self) -> &OpenHandsClient { &self.client } @@ -1540,7 +1547,7 @@ impl IssueSessionRunner { ); } if let Err(error) = self - .wait_for_active_turn_to_finish(&mut active_session.stream, observer) + .wait_for_active_turn_to_finish(&mut active_session.stream) .await { return Err(IssueSessionError::RehydrationFailed(format!( @@ -2152,7 +2159,7 @@ impl IssueSessionRunner { run: &RunAttempt, mut active_session: ActiveSession, launch_reported: bool, - observer: &mut O, + _observer: &mut O, ) -> Result, IssueSessionError> where O: IssueSessionObserver, @@ -2162,7 +2169,7 @@ impl IssueSessionRunner { && turn_is_in_progress(status) { if let Err(error) = self - .wait_for_active_turn_to_finish(&mut active_session.stream, observer) + .wait_for_active_turn_to_finish(&mut active_session.stream) .await { return self @@ -2382,7 +2389,7 @@ impl IssueSessionRunner { ); } if let Err(error) = self - .wait_for_active_turn_to_finish(&mut active_session.stream, observer) + .wait_for_active_turn_to_finish(&mut active_session.stream) .await { return self @@ -3459,21 +3466,23 @@ impl IssueSessionRunner { }) .map_err(|error| error.to_string()) } - IssueSessionPromptKind::Continuation => Ok(append_memory_scope_guidance( - build_continuation_guidance(issue, run), - self.config.memory.as_ref(), - )), + IssueSessionPromptKind::Continuation => { + let prompt = append_memory_scope_guidance( + build_continuation_guidance(issue, run), + self.config.memory.as_ref(), + ); + Ok(append_attempt_continuation( + prompt, + self.config.continuation_prompt.as_deref(), + )) + } } } - async fn wait_for_active_turn_to_finish( + async fn wait_for_active_turn_to_finish( &self, stream: &mut RuntimeEventStream, - observer: &mut O, - ) -> Result<(), OpenHandsError> - where - O: IssueSessionObserver, - { + ) -> Result<(), OpenHandsError> { if stream .state_mirror() .execution_status() @@ -3506,7 +3515,10 @@ impl IssueSessionRunner { ), }); } - Ok(Ok(Some(event))) => observe_event(observer, &event), + // This is a prior turn. The stream mirror and event cache + // still consume its events, but the current worker observer + // must not attribute its commands to the new parent attempt. + Ok(Ok(Some(_event))) => {} Ok(Ok(None)) => {} Ok(Err(error)) => { if stream @@ -3726,10 +3738,21 @@ impl IssueSessionRunner { where O: IssueSessionObserver, { + let previously_observed = session + .stream + .event_cache() + .items() + .iter() + .map(|event| event.id.clone()) + .collect::>(); if let Ok(inserted) = session.stream.reconcile_events().await && inserted > 0 { - observe_latest_event(observer, &session.stream); + observe_reconciled_events( + observer, + session.stream.event_cache().items(), + &previously_observed, + ); if let Some(tracker) = tracker { tracker.record_reconciled_events( inserted as u64, @@ -4354,15 +4377,29 @@ where ); } -fn observe_latest_event(observer: &mut O, stream: &RuntimeEventStream) -where +fn observe_reconciled_events( + observer: &mut O, + events: &[EventEnvelope], + previously_observed: &HashSet, +) where O: IssueSessionObserver, { - if let Some(event) = stream.event_cache().items().last() { + for event in events + .iter() + .filter(|event| !previously_observed.contains(&event.id)) + { observe_event(observer, event); } } +fn append_attempt_continuation(mut prompt: String, continuation: Option<&str>) -> String { + if let Some(continuation) = continuation { + prompt.push_str("\n\n"); + prompt.push_str(continuation); + } + prompt +} + fn failed_outcome(summary: impl Into, error: impl Into) -> NormalizedOutcome { NormalizedOutcome { kind: WorkerOutcomeKind::Failed, @@ -4761,6 +4798,152 @@ mod tests { } } + #[derive(Default)] + struct ReconciledEventObserver { + event_ids: Vec, + } + + impl IssueSessionObserver for ReconciledEventObserver { + fn on_runtime_event( + &mut self, + _observed_at: TimestampMs, + event_id: Option, + _event_kind: Option, + _summary: Option, + _payload: Option, + ) { + if let Some(event_id) = event_id { + self.event_ids.push(event_id); + } + } + } + + #[test] + fn reconciliation_forwards_every_new_event_in_cache_order() { + let timestamp = Utc::now(); + let events = vec![ + EventEnvelope::new("prior", timestamp, "runtime", "MessageEvent", json!({})), + EventEnvelope::new( + "command-start", + timestamp + chrono::Duration::milliseconds(1), + "agent", + "ActionEvent", + json!({"command":"cargo test"}), + ), + EventEnvelope::new( + "command-finish", + timestamp + chrono::Duration::milliseconds(2), + "tool", + "ObservationEvent", + json!({"exit_code":0}), + ), + ]; + let mut observer = ReconciledEventObserver::default(); + observe_reconciled_events(&mut observer, &events, &HashSet::from(["prior".to_owned()])); + + assert_eq!( + observer.event_ids, + vec!["command-start".to_owned(), "command-finish".to_owned()] + ); + } + + #[tokio::test] + async fn prior_turn_wait_keeps_its_commands_out_of_the_current_attempt() { + let server = FakeOpenHandsServer::start() + .await + .expect("fake server should start"); + let client = OpenHandsClient::new(TransportConfig::new(server.base_url())); + let conversation = client + .create_conversation(&ConversationCreateRequest::doctor_probe( + "/tmp/opensymphony-prior-turn", + "/tmp/opensymphony-prior-turn/.opensymphony/openhands", + Some("fake-model".to_string()), + None, + )) + .await + .expect("conversation should be created"); + let mut stream = client + .attach_runtime_stream( + conversation.conversation_id, + RuntimeStreamConfig { + readiness_timeout: Duration::from_secs(2), + reconnect_initial_backoff: Duration::from_millis(25), + reconnect_max_backoff: Duration::from_millis(25), + max_reconnect_attempts: 1, + replay_existing_events_on_attach: false, + }, + ) + .await + .expect("runtime stream should attach"); + server + .emit_state_update(conversation.conversation_id, "running") + .await + .expect("prior turn should run"); + stream + .next_event() + .await + .expect("running event") + .expect("running event payload"); + let prior_command = EventEnvelope::new( + "prior-command", + Utc::now(), + "agent", + "ActionEvent", + json!({"command": "cargo test --test prior"}), + ); + server + .insert_event(conversation.conversation_id, prior_command) + .await + .expect("prior command should be delivered"); + server + .emit_state_update(conversation.conversation_id, "finished") + .await + .expect("prior turn should finish"); + + let config = IssueSessionRunnerConfig { + terminal_wait_timeout: Duration::from_secs(2), + ..IssueSessionRunnerConfig::default() + }; + let runner = IssueSessionRunner::new(client, config); + runner + .wait_for_active_turn_to_finish(&mut stream) + .await + .expect("prior turn should drain"); + + let baseline_event_ids = stream + .event_cache() + .items() + .iter() + .map(|event| event.id.clone()) + .collect::>(); + assert!(baseline_event_ids.contains("prior-command")); + let current_command = EventEnvelope::new( + "current-command", + Utc::now() + chrono::Duration::milliseconds(1), + "agent", + "ActionEvent", + json!({"command": "cargo test --test current"}), + ); + let mut observer = ReconciledEventObserver::default(); + let mut events = stream.event_cache().items().to_vec(); + events.push(current_command); + observe_reconciled_events(&mut observer, &events, &baseline_event_ids); + assert_eq!(observer.event_ids, vec!["current-command".to_owned()]); + } + + #[test] + fn reused_conversation_receives_current_attempt_guidance() { + let prompt = append_attempt_continuation( + "Continue work on the existing issue conversation.".to_owned(), + Some("run_id=run-parent-2 attempt=2 receipt=evidence/final-verification.json"), + ); + + assert!(prompt.contains("Continue work on the existing issue conversation.")); + assert!(prompt.contains("run_id=run-parent-2")); + assert!(prompt.contains("attempt=2")); + assert!(prompt.contains("evidence/final-verification.json")); + } + #[test] fn openhands_binds_parent_scope_without_creating_a_leaf_envelope() { let root = tempfile::tempdir().expect("temporary workspace root"); @@ -5406,6 +5589,7 @@ mod tests { memory: None, repository_instructions: None, terminal_prompt: None, + continuation_prompt: None, }, ); diff --git a/crates/opensymphony-orchestrator/src/hierarchy.rs b/crates/opensymphony-orchestrator/src/hierarchy.rs index 6a7e145c1..542d6fed9 100644 --- a/crates/opensymphony-orchestrator/src/hierarchy.rs +++ b/crates/opensymphony-orchestrator/src/hierarchy.rs @@ -8,6 +8,8 @@ use crate::opensymphony_domain::{ use serde::{Deserialize, Deserializer, Serialize}; use thiserror::Error; +use super::parent_integration::ParentIntegrationController; + pub const HIERARCHY_STATE_SCHEMA_VERSION: u32 = 1; const MAX_INACTIVE_LEASE_HISTORY: usize = 256; @@ -664,6 +666,11 @@ pub struct DurableOrchestratorState { /// parents after the completed workspace has been cleaned up. #[serde(default)] pub terminal_orchestrator_issues: BTreeSet, + /// One restart-safe integration controller per repository-neutral parent. + /// Completed records remain available as descendant evidence for higher + /// ancestors; cleanup policy is handled by the later subtree-cleanup slice. + #[serde(default)] + pub parent_integrations: BTreeMap, } impl Default for DurableOrchestratorState { @@ -675,6 +682,7 @@ impl Default for DurableOrchestratorState { run_hierarchy_generations: BTreeMap::new(), run_started_at_by_issue: BTreeMap::new(), terminal_orchestrator_issues: BTreeSet::new(), + parent_integrations: BTreeMap::new(), } } } @@ -692,6 +700,21 @@ impl DurableOrchestratorState { }) { return Err("durable hierarchy snapshot has invalid identity or generation".to_owned()); } + if self + .parent_integrations + .iter() + .any(|(parent_id, controller)| { + parent_id != &controller.parent_id + || controller.hierarchy_generation == 0 + || controller.state_version + < controller + .transitions + .last() + .map_or(0, |transition| transition.state_version) + }) + { + return Err("durable parent integration controller is inconsistent".to_owned()); + } Ok(()) } diff --git a/crates/opensymphony-orchestrator/src/lib.rs b/crates/opensymphony-orchestrator/src/lib.rs index 1bdf18e1f..4e3bc55a8 100644 --- a/crates/opensymphony-orchestrator/src/lib.rs +++ b/crates/opensymphony-orchestrator/src/lib.rs @@ -1,4 +1,5 @@ mod hierarchy; +mod parent_integration; mod scheduler; mod selection; @@ -20,6 +21,13 @@ pub use hierarchy::{ HierarchyReconciliation, HierarchySnapshot, LeaseError, LeaseKind, LeaseOwner, LeaseRecord, LeaseResource, ParentEligibilityEvidence, ProviderEvidenceBoundary, RequiredMergeCommit, }; +pub use parent_integration::{ + ParentAttemptRoot, ParentAttemptStatus, ParentCleanupReceipt, ParentCleanupStatus, + ParentCommandReceipt, ParentFinalEvidence, ParentIntegrationController, ParentIntegrationError, + ParentIntegrationState, ParentRepositoryTarget, ParentResourceReceipt, ParentResourceStatus, + ParentRetryClassification, ParentSideEffectIntent, ParentSideEffectReceipt, ParentTransition, + ParentVerificationAttempt, parent_command_identity, +}; pub use scheduler::{ HarnessRouteDecision, RecoveredRun, RecoveryRecord, RetryExhaustionRecord, RetryPendingRecord, Scheduler, SchedulerConfig, SchedulerError, TrackerBackend, WorkerAbortReason, WorkerBackend, diff --git a/crates/opensymphony-orchestrator/src/parent_integration.rs b/crates/opensymphony-orchestrator/src/parent_integration.rs new file mode 100644 index 000000000..3ae30f21f --- /dev/null +++ b/crates/opensymphony-orchestrator/src/parent_integration.rs @@ -0,0 +1,2208 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use crate::opensymphony_domain::{ + CanonicalRepositoryId, IssueId, ParentVerificationEvidence, TimestampMs, +}; +use crate::opensymphony_workspace::redact_runtime_diagnostic; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +const MAX_TRANSITIONS: usize = 256; +const MAX_ATTEMPTS: usize = 64; +const MAX_LOG_BYTES: usize = 16 * 1024; +const MAX_COMMAND_BYTES: usize = 4 * 1024; +const MAX_COMMAND_ID_BYTES: usize = 256; +const MAX_RESOURCE_RECEIPTS: usize = 64; +const MAX_COMMAND_RECEIPTS: usize = MAX_RESOURCE_RECEIPTS / 2; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentIntegrationState { + WaitingForChildren, + WaitingForChildMerges, + AcquiringChildLeases, + PreparingIntegrationWorkspace, + RefreshingRepositories, + Integrating, + FinalVerification, + Finalizing, + Completed, + Blocked { reason: String }, + Failed { reason: String }, + Canceled { reason: String }, +} + +impl ParentIntegrationState { + pub fn terminal(&self) -> bool { + matches!( + self, + Self::Completed | Self::Failed { .. } | Self::Canceled { .. } + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentRetryClassification { + None, + Retryable, + CleanupThenRetry, + OperatorAction, + Terminal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentSideEffectIntent { + pub kind: String, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentSideEffectReceipt { + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentTransition { + pub state_version: u64, + pub previous: ParentIntegrationState, + pub next: ParentIntegrationState, + pub reason: String, + pub idempotency_key: String, + pub input_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_effect_intent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_receipt: Option, + pub retry_classification: ParentRetryClassification, + pub occurred_at: TimestampMs, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentAttemptRoot { + #[default] + ParentRoot, + CheckoutHandle(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentAttemptStatus { + Running, + Passed, + Failed, + TimedOut, + Canceled, + Indeterminate, +} + +impl ParentAttemptStatus { + fn terminal(self) -> bool { + self != Self::Running + } + + pub fn passed(self) -> bool { + self == Self::Passed + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentResourceStatus { + Allocated, + Released, + Collision, + CleanupFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentResourceReceipt { + pub kind: String, + pub identifier: String, + pub status: ParentResourceStatus, + pub occurred_at: TimestampMs, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentCleanupStatus { + Pending, + Succeeded, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentCleanupReceipt { + pub status: ParentCleanupStatus, + pub occurred_at: TimestampMs, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentCommandReceipt { + pub command_id: String, + /// Bounded redacted display for diagnostics. + pub command: String, + /// SHA-256 identity of the exact transient harness command. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub command_hash: String, + #[serde(default)] + pub root: ParentAttemptRoot, + pub started_at: TimestampMs, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentVerificationAttempt { + pub id: String, + pub name: String, + pub idempotency_key: String, + pub root: ParentAttemptRoot, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + pub started_at: TimestampMs, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + pub timeout_ms: u64, + pub status: ParentAttemptStatus, + #[serde(default)] + pub commands: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(default)] + pub bounded_log: String, + #[serde(default)] + pub log_truncated: bool, + #[serde(default)] + pub resources: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup: Option, + pub input_version: String, + #[serde(default)] + pub verified_repository_commits: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentRepositoryTarget { + pub repository_id: CanonicalRepositoryId, + pub checkout_handle: String, + #[serde(default)] + pub relative_path: PathBuf, + pub target_commit: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentFinalEvidence { + pub attempt_id: String, + #[serde(default)] + pub conversation_id: String, + #[serde(default)] + pub input_version: String, + pub repository_commits: BTreeMap, + pub recorded_at: TimestampMs, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentIntegrationController { + pub parent_id: IssueId, + pub hierarchy_generation: u64, + pub state_version: u64, + pub state: ParentIntegrationState, + // Transition history is bounded, so admission identity must survive pruning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_input_version: Option, + #[serde(default)] + pub next_attempt_sequence: u64, + #[serde(default)] + pub transitions: Vec, + #[serde(default)] + pub targets: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(default)] + pub attempts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ParentIntegrationError { + #[error("parent controller generation must be greater than zero")] + InvalidGeneration, + #[error("invalid parent transition from {from:?} to {to:?}")] + InvalidTransition { + from: ParentIntegrationState, + to: ParentIntegrationState, + }, + #[error("idempotency key `{0}` was reused with different transition facts")] + IdempotencyConflict(String), + #[error("parent controller is bound to conversation `{expected}`, not `{actual}`")] + ConversationMismatch { expected: String, actual: String }, + #[error("checkout handle `{0}` is not present in the verified parent target map")] + UnknownCheckoutHandle(String), + #[error("verification attempt `{0}` was not found")] + UnknownAttempt(String), + #[error("verification attempt `{0}` is already terminal")] + AttemptAlreadyTerminal(String), + #[error("verification attempt `{0}` cannot start before cleanup and baseline verification")] + AttemptNotReady(String), + #[error("resource `{kind}:{identifier}` is already allocated")] + ResourceCollision { kind: String, identifier: String }, + #[error("final verification requires a passed attempt for the current repository targets")] + FinalVerificationIncomplete, + #[error("parent verification evidence is invalid: {0}")] + InvalidVerificationEvidence(String), +} + +impl ParentIntegrationController { + pub fn new( + parent_id: IssueId, + hierarchy_generation: u64, + ) -> Result { + if hierarchy_generation == 0 { + return Err(ParentIntegrationError::InvalidGeneration); + } + Ok(Self { + parent_id, + hierarchy_generation, + state_version: 0, + state: ParentIntegrationState::WaitingForChildren, + admission_input_version: None, + next_attempt_sequence: 0, + transitions: Vec::new(), + targets: BTreeMap::new(), + conversation_id: None, + attempts: Vec::new(), + final_evidence: None, + }) + } + + pub fn admit( + &mut self, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + if let Some(admitted_input) = self.admission_input_version.as_deref() { + return if admitted_input == input_version { + Ok(()) + } else { + Err(ParentIntegrationError::IdempotencyConflict(format!( + "parent:{}:{}:admission", + self.parent_id, self.hierarchy_generation + ))) + }; + } + let previous = self.clone(); + let result = (|| { + self.transition( + ParentIntegrationState::WaitingForChildMerges, + "required children reached terminal orchestrator outcomes", + format!( + "parent:{}:{}:children", + self.parent_id, self.hierarchy_generation + ), + input_version, + None, + Some(receipt("verified", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + self.transition( + ParentIntegrationState::AcquiringChildLeases, + "provider merge evidence is complete", + format!( + "parent:{}:{}:merges", + self.parent_id, self.hierarchy_generation + ), + input_version, + None, + Some(receipt("verified", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + self.transition( + ParentIntegrationState::PreparingIntegrationWorkspace, + "generation-bound descendant leases were acquired", + format!( + "parent:{}:{}:leases", + self.parent_id, self.hierarchy_generation + ), + input_version, + Some(intent( + "acquire_descendant_leases", + format!( + "parent:{}:{}:leases", + self.parent_id, self.hierarchy_generation + ), + )), + Some(receipt("succeeded", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + Ok(()) + })(); + if let Err(error) = result { + *self = previous; + return Err(error); + } + self.admission_input_version = Some(input_version.to_owned()); + Ok(()) + } + + pub fn record_workspace_prepared( + &mut self, + targets: impl IntoIterator, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let targets = targets + .into_iter() + .map(|target| (target.repository_id.clone(), target)) + .collect::>(); + self.targets = targets; + if self.state == ParentIntegrationState::PreparingIntegrationWorkspace { + self.transition( + ParentIntegrationState::RefreshingRepositories, + "parent integration root and checkout map were verified", + format!( + "parent:{}:{}:workspace", + self.parent_id, self.hierarchy_generation + ), + input_version, + Some(intent( + "prepare_parent_workspace", + format!( + "parent:{}:{}:workspace", + self.parent_id, self.hierarchy_generation + ), + )), + Some(receipt("succeeded", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + } + self.record_baseline_verified(input_version, occurred_at) + } + + pub fn record_baseline_verified( + &mut self, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + if !self.cleanup_complete() { + return Err(ParentIntegrationError::AttemptNotReady( + "baseline-refresh".to_owned(), + )); + } + let refresh_cycle = self + .attempts + .last() + .map_or_else(|| "initial".to_owned(), |attempt| attempt.id.clone()); + self.transition( + ParentIntegrationState::Integrating, + "all parent integration checkouts retain their recorded target commits as verified baselines", + format!( + "parent:{}:{}:refresh:{refresh_cycle}:{input_version}", + self.parent_id, self.hierarchy_generation, + ), + input_version, + Some(intent( + "refresh_verified_baseline", + format!( + "parent:{}:{}:refresh:{refresh_cycle}:{input_version}", + self.parent_id, self.hierarchy_generation, + ), + )), + Some(receipt("succeeded", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn start_attempt( + &mut self, + name: impl Into, + idempotency_key: impl Into, + root: ParentAttemptRoot, + conversation_id: impl Into, + timeout_ms: u64, + input_version: impl Into, + started_at: TimestampMs, + ) -> Result { + let conversation_id = conversation_id.into(); + let id = self.start_attempt_intent( + name, + idempotency_key, + root, + timeout_ms, + input_version, + started_at, + )?; + self.attach_attempt_conversation(&id, &conversation_id)?; + Ok(id) + } + + pub fn start_attempt_intent( + &mut self, + name: impl Into, + idempotency_key: impl Into, + root: ParentAttemptRoot, + timeout_ms: u64, + input_version: impl Into, + started_at: TimestampMs, + ) -> Result { + let name = name.into(); + let idempotency_key = idempotency_key.into(); + let input_version = input_version.into(); + if let Some(existing) = self + .attempts + .iter() + .find(|attempt| attempt.idempotency_key == idempotency_key) + { + if existing.name == name + && existing.root == root + && existing.timeout_ms == timeout_ms + && existing.input_version == input_version + { + return Ok(existing.id.clone()); + } + return Err(ParentIntegrationError::IdempotencyConflict(idempotency_key)); + } + if !matches!( + self.state, + ParentIntegrationState::Integrating | ParentIntegrationState::FinalVerification + ) || !self.cleanup_complete() + { + return Err(ParentIntegrationError::AttemptNotReady(name)); + } + if let ParentAttemptRoot::CheckoutHandle(handle) = &root + && !self + .targets + .values() + .any(|target| target.checkout_handle == *handle) + { + return Err(ParentIntegrationError::UnknownCheckoutHandle( + handle.clone(), + )); + } + if self.attempts.len() >= MAX_ATTEMPTS { + let Some(removable) = self + .attempts + .iter() + .position(|attempt| attempt.status.terminal()) + else { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "nonterminal parent attempt receipts exceed their bound".to_owned(), + )); + }; + self.attempts.remove(removable); + } + self.next_attempt_sequence = self.next_attempt_sequence.saturating_add(1); + let id = format!("parent-attempt-{}", self.next_attempt_sequence); + self.attempts.push(ParentVerificationAttempt { + id: id.clone(), + name, + idempotency_key, + root, + conversation_id: None, + started_at, + finished_at: None, + timeout_ms, + status: ParentAttemptStatus::Running, + commands: Vec::new(), + exit_code: None, + bounded_log: String::new(), + log_truncated: false, + resources: Vec::new(), + cleanup: None, + input_version, + verified_repository_commits: BTreeMap::new(), + }); + Ok(id) + } + + pub fn attach_attempt_conversation( + &mut self, + attempt_id: &str, + conversation_id: &str, + ) -> Result<(), ParentIntegrationError> { + if let Some(expected) = &self.conversation_id { + if expected != conversation_id { + return Err(ParentIntegrationError::ConversationMismatch { + expected: expected.clone(), + actual: conversation_id.to_owned(), + }); + } + } else { + self.conversation_id = Some(conversation_id.to_owned()); + } + let attempt = self.attempt_mut(attempt_id)?; + match &attempt.conversation_id { + Some(expected) if expected != conversation_id => { + Err(ParentIntegrationError::ConversationMismatch { + expected: expected.clone(), + actual: conversation_id.to_owned(), + }) + } + Some(_) => Ok(()), + None => { + attempt.conversation_id = Some(conversation_id.to_owned()); + Ok(()) + } + } + } + + pub fn record_verification_evidence( + &mut self, + attempt_id: &str, + evidence: &ParentVerificationEvidence, + ) -> Result { + if evidence.schema_version != 1 { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "unsupported schema version".to_owned(), + )); + } + let expected_commits = self + .targets + .iter() + .map(|(repository_id, target)| (repository_id.clone(), target.target_commit.clone())) + .collect::>(); + if evidence.repository_commits != expected_commits { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "repository commits do not match the current target map".to_owned(), + )); + } + let attempt = self.attempt_mut(attempt_id)?; + let selected_command = attempt.commands.iter().rev().find(|command| { + !evidence.command_hash.is_empty() + && command.command_hash == evidence.command_hash + && match &command.root { + ParentAttemptRoot::ParentRoot => evidence.root == "parent_root", + ParentAttemptRoot::CheckoutHandle(handle) => evidence.root == *handle, + } + && command.finished_at.is_some() + && command.exit_code.is_some() + }); + let selected_exit_code = selected_command.and_then(|command| command.exit_code); + if evidence.command_hash.is_empty() || selected_exit_code.is_none() { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "selected command does not match a completed runtime command".to_owned(), + )); + } + attempt.exit_code = selected_exit_code; + attempt.verified_repository_commits = evidence.repository_commits.clone(); + Ok(selected_exit_code == Some(0) + && attempt + .cleanup + .as_ref() + .is_some_and(|cleanup| cleanup.status == ParentCleanupStatus::Succeeded) + && active_resource_keys(attempt).is_empty()) + } + + pub fn observe_command_started( + &mut self, + attempt_id: &str, + command_id: &str, + command: &str, + root: ParentAttemptRoot, + observed_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + if command_id.trim().is_empty() || command.trim().is_empty() { + return Ok(()); + } + if command_id.len() > MAX_COMMAND_ID_BYTES || command.len() > MAX_COMMAND_BYTES { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "foreground command identity exceeds its evidence bound".to_owned(), + )); + } + let attempt = self.attempt_mut(attempt_id)?; + if observed_at < attempt.started_at { + return Ok(()); + } + let command_hash = parent_command_identity(command); + let command = redact_runtime_diagnostic(command); + if attempt.status != ParentAttemptStatus::Running { + return Err(ParentIntegrationError::AttemptAlreadyTerminal( + attempt_id.to_owned(), + )); + } + if let Some(existing) = attempt + .commands + .iter() + .find(|receipt| receipt.command_id == command_id) + { + return if existing.command_hash == command_hash && existing.root == root { + Ok(()) + } else { + Err(ParentIntegrationError::InvalidVerificationEvidence( + "a command id was replayed with different command text".to_owned(), + )) + }; + } + if observed_at.as_u64() + >= attempt + .started_at + .as_u64() + .saturating_add(attempt.timeout_ms) + { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "foreground command started after the attempt deadline".to_owned(), + )); + } + if let Some(active_command_id) = attempt + .commands + .iter() + .rev() + .find(|receipt| receipt.finished_at.is_none()) + .map(|receipt| receipt.command_id.as_str()) + { + return Err(ParentIntegrationError::ResourceCollision { + kind: "foreground_process".to_owned(), + identifier: active_command_id.to_owned(), + }); + } + if active_resource_keys(attempt) + .into_iter() + .any(|(kind, identifier)| kind == "foreground_process" && identifier == command_id) + { + return Err(ParentIntegrationError::ResourceCollision { + kind: "foreground_process".to_owned(), + identifier: command_id.to_owned(), + }); + } + if attempt.commands.len() >= MAX_COMMAND_RECEIPTS { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "attempt command receipts exceed their bound".to_owned(), + )); + } + if attempt.resources.len() >= MAX_RESOURCE_RECEIPTS.saturating_sub(1) { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "attempt resource receipts cannot reserve a teardown receipt".to_owned(), + )); + } + attempt.commands.push(ParentCommandReceipt { + command_id: command_id.to_owned(), + command, + command_hash, + root, + started_at: observed_at, + finished_at: None, + exit_code: None, + }); + attempt.exit_code = None; + attempt.cleanup = Some(ParentCleanupReceipt { + status: ParentCleanupStatus::Pending, + occurred_at: observed_at, + detail: Some("foreground command is running".to_owned()), + }); + self.allocate_resource(attempt_id, "foreground_process", command_id, observed_at) + } + + pub fn observe_command_finished( + &mut self, + attempt_id: &str, + command_id: &str, + exit_code: i32, + redacted_output: Option<&str>, + observed_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let attempt = self.attempt_mut(attempt_id)?; + if observed_at < attempt.started_at { + return Ok(()); + } + let Some(command_index) = attempt + .commands + .iter() + .rposition(|command| command.command_id == command_id) + else { + return Ok(()); + }; + if observed_at < attempt.commands[command_index].started_at { + return Ok(()); + } + if let Some(finished_at) = attempt.commands[command_index].finished_at { + return if attempt.commands[command_index].exit_code == Some(exit_code) { + Ok(()) + } else { + Err(ParentIntegrationError::InvalidVerificationEvidence( + format!( + "command {command_id} completion replay changed its exit code after {finished_at:?}" + ), + )) + }; + } + if observed_at.as_u64() + >= attempt + .started_at + .as_u64() + .saturating_add(attempt.timeout_ms) + { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "foreground command completed after the attempt deadline".to_owned(), + )); + } + attempt.commands[command_index].finished_at = Some(observed_at); + attempt.commands[command_index].exit_code = Some(exit_code); + attempt.exit_code = Some(exit_code); + attempt.cleanup = Some(ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: observed_at, + detail: Some("harness reported foreground process completion".to_owned()), + }); + if let Some(output) = redacted_output { + self.append_log(attempt_id, output)?; + } + self.release_resource( + attempt_id, + "foreground_process", + command_id, + true, + Some("harness reported foreground process completion".to_owned()), + observed_at, + ) + } + + pub fn observe_harness_stopped( + &mut self, + attempt_id: &str, + detail: &str, + observed_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let foreground_processes = { + let attempt = self.attempt_mut(attempt_id)?; + active_resource_keys(attempt) + .into_iter() + .filter_map(|(kind, identifier)| { + (kind == "foreground_process").then_some(identifier) + }) + .collect::>() + }; + for identifier in foreground_processes { + self.release_resource( + attempt_id, + "foreground_process", + &identifier, + true, + Some(detail.to_owned()), + observed_at, + )?; + } + let attempt = self.attempt_mut(attempt_id)?; + let remaining = active_resource_keys(attempt); + attempt.cleanup = Some(ParentCleanupReceipt { + status: if remaining.is_empty() { + ParentCleanupStatus::Succeeded + } else { + ParentCleanupStatus::Pending + }, + occurred_at: observed_at, + detail: Some(if remaining.is_empty() { + detail.to_owned() + } else { + format!( + "{detail}; {} non-process resource(s) still require cleanup", + remaining.len() + ) + }), + }); + Ok(()) + } + + pub fn append_log( + &mut self, + attempt_id: &str, + redacted: &str, + ) -> Result<(), ParentIntegrationError> { + let attempt = self.attempt_mut(attempt_id)?; + if attempt.log_truncated || redacted.is_empty() { + return Ok(()); + } + if !attempt.bounded_log.is_empty() { + attempt.bounded_log.push('\n'); + } + let remaining = MAX_LOG_BYTES.saturating_sub(attempt.bounded_log.len()); + if redacted.len() <= remaining { + attempt.bounded_log.push_str(redacted); + } else { + let mut boundary = remaining.min(redacted.len()); + while boundary > 0 && !redacted.is_char_boundary(boundary) { + boundary -= 1; + } + attempt.bounded_log.push_str(&redacted[..boundary]); + attempt.log_truncated = true; + } + Ok(()) + } + + pub fn allocate_resource( + &mut self, + attempt_id: &str, + kind: impl Into, + identifier: impl Into, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let kind = kind.into(); + let identifier = identifier.into(); + let collision = self.attempts.iter().any(|attempt| { + active_resource_keys(attempt) + .into_iter() + .any(|(active_kind, active_identifier)| { + active_kind == kind && active_identifier == identifier + }) + }); + if collision { + let attempt = self.attempt_mut(attempt_id)?; + if attempt.resources.len() < MAX_RESOURCE_RECEIPTS.saturating_sub(1) { + attempt.resources.push(ParentResourceReceipt { + kind: kind.clone(), + identifier: identifier.clone(), + status: ParentResourceStatus::Collision, + occurred_at, + detail: Some("resource is owned by another parent attempt".to_owned()), + }); + } + return Err(ParentIntegrationError::ResourceCollision { kind, identifier }); + } + let attempt = self.attempt_mut(attempt_id)?; + if attempt.resources.len() >= MAX_RESOURCE_RECEIPTS.saturating_sub(1) { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "attempt resource receipts cannot reserve a teardown receipt".to_owned(), + )); + } + attempt.resources.push(ParentResourceReceipt { + kind, + identifier, + status: ParentResourceStatus::Allocated, + occurred_at, + detail: None, + }); + Ok(()) + } + + pub fn release_resource( + &mut self, + attempt_id: &str, + kind: &str, + identifier: &str, + succeeded: bool, + detail: Option, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let attempt = self.attempt_mut(attempt_id)?; + if attempt.resources.len() >= MAX_RESOURCE_RECEIPTS { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "attempt resource receipts exceed their bound".to_owned(), + )); + } + attempt.resources.push(ParentResourceReceipt { + kind: kind.to_owned(), + identifier: identifier.to_owned(), + status: if succeeded { + ParentResourceStatus::Released + } else { + ParentResourceStatus::CleanupFailed + }, + occurred_at, + detail, + }); + Ok(()) + } + + pub fn finish_attempt( + &mut self, + attempt_id: &str, + status: ParentAttemptStatus, + exit_code: Option, + cleanup: ParentCleanupReceipt, + finished_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + if !status.terminal() { + return Err(ParentIntegrationError::AttemptAlreadyTerminal( + attempt_id.to_owned(), + )); + } + let attempt = self.attempt_mut(attempt_id)?; + if attempt.status.terminal() { + return Err(ParentIntegrationError::AttemptAlreadyTerminal( + attempt_id.to_owned(), + )); + } + attempt.status = status; + attempt.exit_code = exit_code; + attempt.finished_at = Some(finished_at); + attempt.cleanup = Some(cleanup); + Ok(()) + } + + pub fn prepare_retry( + &mut self, + attempt_id: &str, + reason: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let attempt = self + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .ok_or_else(|| ParentIntegrationError::UnknownAttempt(attempt_id.to_owned()))?; + let input_version = attempt.input_version.clone(); + let passed = attempt.status.passed(); + let cleanup = attempt.cleanup.clone(); + let attempt_key = attempt.idempotency_key.clone(); + self.transition( + ParentIntegrationState::RefreshingRepositories, + reason, + format!("{}:retry", attempt_key), + &input_version, + Some(intent( + "cleanup_and_refresh", + format!("{}:retry", attempt_key), + )), + cleanup.as_ref().map(|cleanup| { + receipt( + match cleanup.status { + ParentCleanupStatus::Succeeded => "succeeded", + ParentCleanupStatus::Pending => "pending", + ParentCleanupStatus::Failed => "failed", + }, + cleanup.detail.clone(), + ) + }), + if passed { + ParentRetryClassification::Retryable + } else { + ParentRetryClassification::CleanupThenRetry + }, + occurred_at, + )?; + Ok(()) + } + + pub fn reconcile_restart( + &mut self, + harness_reconciled: bool, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let Some(index) = self + .attempts + .iter() + .rposition(|attempt| attempt.status == ParentAttemptStatus::Running) + else { + return Ok(()); + }; + if harness_reconciled { + return Ok(()); + } + let attempt_id = self.attempts[index].id.clone(); + let launch_never_attached = self.attempts[index].conversation_id.is_none() + && self.attempts[index].commands.is_empty() + && active_resource_keys(&self.attempts[index]).is_empty(); + self.attempts[index].status = ParentAttemptStatus::Indeterminate; + self.attempts[index].finished_at = Some(occurred_at); + self.attempts[index].cleanup = Some(ParentCleanupReceipt { + status: if launch_never_attached { + ParentCleanupStatus::Succeeded + } else { + ParentCleanupStatus::Pending + }, + occurred_at, + detail: Some(if launch_never_attached { + "attempt intent persisted before worker launch; no conversation, command, or resource was attached".to_owned() + } else { + "harness state and attempt-owned teardown were unavailable after restart".to_owned() + }), + }); + self.prepare_retry( + &attempt_id, + "nonterminal parent attempt became indeterminate after restart", + occurred_at, + ) + } + + pub fn reconcile_terminal_run_after_restart( + &mut self, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let Some(index) = self + .attempts + .iter() + .rposition(|attempt| attempt.status == ParentAttemptStatus::Running) + else { + return Ok(()); + }; + let attempt_id = self.attempts[index].id.clone(); + let resources_released = active_resource_keys(&self.attempts[index]).is_empty(); + self.attempts[index].status = ParentAttemptStatus::Indeterminate; + self.attempts[index].finished_at = Some(occurred_at); + self.attempts[index].cleanup = Some(ParentCleanupReceipt { + status: if resources_released { + ParentCleanupStatus::Succeeded + } else { + ParentCleanupStatus::Pending + }, + occurred_at, + detail: Some(if resources_released { + "durable run manifest recorded a terminal harness state before controller outcome persistence".to_owned() + } else { + "durable run manifest is terminal but named attempt resources still require cleanup" + .to_owned() + }), + }); + self.prepare_retry( + &attempt_id, + "terminal harness outcome was not durably applied before restart", + occurred_at, + ) + } + + pub fn complete( + &mut self, + attempt_id: &str, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let expected_commits = self + .targets + .iter() + .map(|(repository_id, target)| (repository_id.clone(), target.target_commit.clone())) + .collect::>(); + if !self.attempts.iter().any(|attempt| { + attempt.id == attempt_id + && attempt.status.passed() + && attempt.input_version == input_version + && attempt.exit_code == Some(0) + && attempt + .cleanup + .as_ref() + .is_some_and(|cleanup| cleanup.status == ParentCleanupStatus::Succeeded) + && attempt.verified_repository_commits == expected_commits + }) { + return Err(ParentIntegrationError::FinalVerificationIncomplete); + } + self.transition( + ParentIntegrationState::FinalVerification, + "parent integration attempt passed", + format!("{attempt_id}:final-verification"), + input_version, + Some(intent( + "accept_harness_final_verification", + format!("{attempt_id}:final-verification"), + )), + Some(receipt("passed", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + self.final_evidence = Some(ParentFinalEvidence { + attempt_id: attempt_id.to_owned(), + conversation_id: self.conversation_id.clone().unwrap_or_default(), + input_version: input_version.to_owned(), + repository_commits: expected_commits, + recorded_at: occurred_at, + }); + self.transition( + ParentIntegrationState::Finalizing, + "final verification evidence was written durably", + format!("{attempt_id}:finalize"), + input_version, + Some(intent( + "persist_final_evidence", + format!("{attempt_id}:finalize"), + )), + Some(receipt("succeeded", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + self.transition( + ParentIntegrationState::Completed, + "all recorded repository targets passed final verification", + format!("{attempt_id}:complete"), + input_version, + None, + Some(receipt("succeeded", None)), + ParentRetryClassification::Terminal, + occurred_at, + )?; + Ok(()) + } + + pub fn cancel( + &mut self, + reason: impl Into, + input_version: &str, + acknowledged: bool, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let reason = reason.into(); + self.transition( + ParentIntegrationState::Canceled { + reason: reason.clone(), + }, + reason, + format!( + "parent:{}:{}:cancel", + self.parent_id, self.hierarchy_generation + ), + input_version, + Some(intent( + "interrupt_parent_harness", + format!( + "parent:{}:{}:cancel", + self.parent_id, self.hierarchy_generation + ), + )), + Some(receipt( + if acknowledged { + "acknowledged" + } else { + "uncertain" + }, + None, + )), + ParentRetryClassification::Terminal, + occurred_at, + )?; + Ok(()) + } + + pub fn current_attempt_id(&self) -> Option<&str> { + self.attempts + .iter() + .rev() + .find(|attempt| attempt.status == ParentAttemptStatus::Running) + .map(|attempt| attempt.id.as_str()) + } + + pub fn current_attempt_deadline(&self) -> Option { + self.attempts + .iter() + .rev() + .find(|attempt| attempt.status == ParentAttemptStatus::Running) + .map(|attempt| { + TimestampMs::new( + attempt + .started_at + .as_u64() + .saturating_add(attempt.timeout_ms), + ) + }) + } + + fn cleanup_complete(&self) -> bool { + self.attempts.iter().all(|attempt| match attempt.status { + ParentAttemptStatus::Running => false, + ParentAttemptStatus::Passed + | ParentAttemptStatus::Failed + | ParentAttemptStatus::TimedOut + | ParentAttemptStatus::Canceled + | ParentAttemptStatus::Indeterminate => { + attempt + .cleanup + .as_ref() + .is_some_and(|cleanup| cleanup.status == ParentCleanupStatus::Succeeded) + && active_resource_keys(attempt).is_empty() + } + }) + } + + fn attempt_mut( + &mut self, + attempt_id: &str, + ) -> Result<&mut ParentVerificationAttempt, ParentIntegrationError> { + self.attempts + .iter_mut() + .find(|attempt| attempt.id == attempt_id) + .ok_or_else(|| ParentIntegrationError::UnknownAttempt(attempt_id.to_owned())) + } + + #[allow(clippy::too_many_arguments)] + fn transition( + &mut self, + next: ParentIntegrationState, + reason: impl Into, + idempotency_key: impl Into, + input_version: &str, + side_effect_intent: Option, + result_receipt: Option, + retry_classification: ParentRetryClassification, + occurred_at: TimestampMs, + ) -> Result { + let reason = reason.into(); + let idempotency_key = idempotency_key.into(); + if let Some(existing) = self + .transitions + .iter() + .find(|transition| transition.idempotency_key == idempotency_key) + { + if existing.next == next + && existing.reason == reason + && existing.input_version == input_version + && existing.side_effect_intent == side_effect_intent + && existing.result_receipt == result_receipt + && existing.retry_classification == retry_classification + { + return Ok(false); + } + return Err(ParentIntegrationError::IdempotencyConflict(idempotency_key)); + } + if !allowed_transition(&self.state, &next) { + return Err(ParentIntegrationError::InvalidTransition { + from: self.state.clone(), + to: next, + }); + } + self.state_version = self.state_version.saturating_add(1); + let previous = std::mem::replace(&mut self.state, next.clone()); + self.transitions.push(ParentTransition { + state_version: self.state_version, + previous, + next, + reason, + idempotency_key, + input_version: input_version.to_owned(), + side_effect_intent, + result_receipt, + retry_classification, + occurred_at, + }); + if self.transitions.len() > MAX_TRANSITIONS { + self.transitions.remove(0); + } + Ok(true) + } +} + +fn active_resource_keys(attempt: &ParentVerificationAttempt) -> Vec<(String, String)> { + let mut latest = BTreeMap::new(); + for resource in &attempt.resources { + if resource.status != ParentResourceStatus::Collision { + latest.insert( + (resource.kind.clone(), resource.identifier.clone()), + resource.status, + ); + } + } + latest + .into_iter() + .filter_map(|(key, status)| { + matches!( + status, + ParentResourceStatus::Allocated | ParentResourceStatus::CleanupFailed + ) + .then_some(key) + }) + .collect() +} + +fn allowed_transition(from: &ParentIntegrationState, to: &ParentIntegrationState) -> bool { + use ParentIntegrationState as State; + matches!( + (from, to), + (State::WaitingForChildren, State::WaitingForChildMerges) + | (State::WaitingForChildMerges, State::AcquiringChildLeases) + | ( + State::AcquiringChildLeases, + State::PreparingIntegrationWorkspace + ) + | ( + State::PreparingIntegrationWorkspace, + State::RefreshingRepositories + ) + | (State::RefreshingRepositories, State::Integrating) + | (State::Integrating, State::RefreshingRepositories) + | (State::Integrating, State::FinalVerification) + | (State::FinalVerification, State::Integrating) + | (State::FinalVerification, State::Finalizing) + | (State::Finalizing, State::Completed) + | (State::Blocked { .. }, State::RefreshingRepositories) + | (_, State::Blocked { .. }) + | (_, State::Failed { .. }) + | (_, State::Canceled { .. }) + ) && !from.terminal() +} + +fn intent(kind: impl Into, idempotency_key: impl Into) -> ParentSideEffectIntent { + ParentSideEffectIntent { + kind: kind.into(), + idempotency_key: idempotency_key.into(), + } +} + +fn receipt(status: impl Into, detail: Option) -> ParentSideEffectReceipt { + ParentSideEffectReceipt { + status: status.into(), + detail, + } +} + +pub fn parent_command_identity(command: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(command.as_bytes()); + format!("sha256:{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn controller() -> ParentIntegrationController { + let mut controller = + ParentIntegrationController::new(IssueId::new("parent").expect("parent id"), 7) + .expect("controller"); + controller + .admit("hierarchy:7", TimestampMs::new(1)) + .expect("admit"); + controller + .record_workspace_prepared( + ["a", "b", "c"] + .into_iter() + .map(|repo| ParentRepositoryTarget { + repository_id: CanonicalRepositoryId::new(format!( + "github:repository:{repo}" + )) + .expect("repository"), + checkout_handle: format!("checkout-{repo}"), + relative_path: PathBuf::from(format!("repositories/{repo}")), + target_commit: format!("commit-{repo}"), + }), + "targets:1", + TimestampMs::new(2), + ) + .expect("workspace"); + controller + } + + fn finish_passed(controller: &mut ParentIntegrationController, id: &str) { + controller + .observe_command_started( + id, + "command-final", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(3), + ) + .expect("command start"); + controller + .observe_command_finished( + id, + "command-final", + 0, + Some("all checks passed"), + TimestampMs::new(4), + ) + .expect("command completion"); + let evidence = ParentVerificationEvidence { + schema_version: 1, + run_id: "run-parent".to_owned(), + attempt: 1, + hierarchy_generation: controller.hierarchy_generation, + repository_commits: controller + .targets + .iter() + .map(|(repository_id, target)| { + (repository_id.clone(), target.target_commit.clone()) + }) + .collect(), + command: "cargo test".to_owned(), + command_hash: parent_command_identity("cargo test"), + root: "parent_root".to_owned(), + }; + assert!( + controller + .record_verification_evidence(id, &evidence) + .expect("verification receipt") + ); + controller + .finish_attempt( + id, + ParentAttemptStatus::Passed, + Some(0), + ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: TimestampMs::new(4), + detail: None, + }, + TimestampMs::new(4), + ) + .expect("finish"); + } + + #[test] + fn no_repair_lifecycle_is_idempotent_and_records_exact_targets() { + let mut controller = controller(); + let attempt = controller + .start_attempt( + "integration", + "attempt:1", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 30_000, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + assert_eq!( + controller + .start_attempt( + "integration", + "attempt:1", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 30_000, + "targets:1", + TimestampMs::new(3), + ) + .expect("idempotent attempt"), + attempt + ); + finish_passed(&mut controller, &attempt); + controller + .complete(&attempt, "targets:1", TimestampMs::new(5)) + .expect("complete"); + + assert_eq!(controller.state, ParentIntegrationState::Completed); + assert_eq!( + controller.state_version as usize, + controller.transitions.len() + ); + assert_eq!( + controller + .final_evidence + .as_ref() + .expect("evidence") + .repository_commits + .len(), + 3 + ); + } + + #[test] + fn checkout_roots_are_topology_neutral_and_generation_bound() { + let mut valid_root = controller(); + assert!( + valid_root + .start_attempt( + "repo check", + "attempt:repo", + ParentAttemptRoot::CheckoutHandle("checkout-b".to_owned()), + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(3), + ) + .is_ok() + ); + let mut unknown_root = controller(); + assert_eq!( + unknown_root.start_attempt( + "unknown repo check", + "attempt:unknown", + ParentAttemptRoot::CheckoutHandle("frontend".to_owned()), + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(3), + ), + Err(ParentIntegrationError::UnknownCheckoutHandle( + "frontend".to_owned() + )) + ); + } + + #[test] + fn timeout_and_indeterminate_attempts_require_cleanup_before_rerun() { + let mut timed_out_controller = controller(); + let attempt = timed_out_controller + .start_attempt( + "integration", + "attempt:1", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + timed_out_controller + .allocate_resource(&attempt, "port", "24001", TimestampMs::new(3)) + .expect("port"); + timed_out_controller + .finish_attempt( + &attempt, + ParentAttemptStatus::TimedOut, + None, + ParentCleanupReceipt { + status: ParentCleanupStatus::Failed, + occurred_at: TimestampMs::new(4), + detail: Some("process remained visible".to_owned()), + }, + TimestampMs::new(4), + ) + .expect("timeout"); + timed_out_controller + .prepare_retry(&attempt, "timeout", TimestampMs::new(5)) + .expect("retry intent"); + assert!( + timed_out_controller + .record_baseline_verified("targets:2", TimestampMs::new(6)) + .is_err() + ); + + let mut failed_without_teardown = controller(); + let failed = failed_without_teardown + .start_attempt( + "integration", + "attempt:failed", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + failed_without_teardown + .finish_attempt( + &failed, + ParentAttemptStatus::Failed, + None, + ParentCleanupReceipt { + status: ParentCleanupStatus::Pending, + occurred_at: TimestampMs::new(4), + detail: Some("no runtime completion event".to_owned()), + }, + TimestampMs::new(4), + ) + .expect("failed attempt"); + failed_without_teardown + .prepare_retry(&failed, "failed", TimestampMs::new(5)) + .expect("retry intent"); + failed_without_teardown + .record_baseline_verified("targets:2", TimestampMs::new(6)) + .expect_err("failed command without teardown blocks baseline refresh"); + + let mut stopped_by_harness = controller(); + let stopped = stopped_by_harness + .start_attempt( + "integration", + "attempt:stopped", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + stopped_by_harness + .observe_command_started( + &stopped, + "command-running", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(4), + ) + .expect("command start"); + stopped_by_harness + .observe_harness_stopped( + &stopped, + "interrupt observed paused state", + TimestampMs::new(103), + ) + .expect("stopped-state cleanup"); + let cleanup = stopped_by_harness.attempts[0] + .cleanup + .clone() + .expect("cleanup receipt"); + stopped_by_harness + .finish_attempt( + &stopped, + ParentAttemptStatus::TimedOut, + None, + cleanup, + TimestampMs::new(103), + ) + .expect("timed out attempt"); + stopped_by_harness + .prepare_retry(&stopped, "timeout", TimestampMs::new(104)) + .expect("retry intent"); + stopped_by_harness + .record_baseline_verified("targets:2", TimestampMs::new(105)) + .expect("stopped-state receipt permits baseline refresh"); + + let mut restarted = controller(); + let running = restarted + .start_attempt( + "integration", + "attempt:restart", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + restarted + .reconcile_restart(false, TimestampMs::new(4)) + .expect("restart reconciliation"); + assert_eq!( + restarted + .attempts + .iter() + .find(|attempt| attempt.id == running) + .expect("attempt") + .status, + ParentAttemptStatus::Indeterminate + ); + restarted + .record_baseline_verified("targets:2", TimestampMs::new(5)) + .expect_err("unknown teardown blocks baseline refresh"); + restarted + .attempts + .iter_mut() + .find(|attempt| attempt.id == running) + .expect("attempt") + .cleanup = Some(ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: TimestampMs::new(6), + detail: Some("recovery cleanup verified".to_owned()), + }); + restarted + .record_baseline_verified("targets:2", TimestampMs::new(7)) + .expect("explicit cleanup permits verified rerun"); + + let mut prelaunch = controller(); + let intent = prelaunch + .start_attempt_intent( + "integration", + "attempt:prelaunch", + ParentAttemptRoot::ParentRoot, + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("persisted launch intent"); + prelaunch + .reconcile_restart(false, TimestampMs::new(4)) + .expect("metadata-only intent reconciliation"); + let attempt = prelaunch + .attempts + .iter() + .find(|attempt| attempt.id == intent) + .expect("attempt"); + assert_eq!(attempt.status, ParentAttemptStatus::Indeterminate); + assert_eq!( + attempt.cleanup.as_ref().map(|cleanup| cleanup.status), + Some(ParentCleanupStatus::Succeeded) + ); + prelaunch + .record_baseline_verified("targets:2", TimestampMs::new(5)) + .expect("metadata-only crash does not block the retry baseline"); + } + + #[test] + fn terminal_run_manifest_recovers_lost_controller_outcome_without_rotating_conversation() { + let mut controller = controller(); + let attempt_id = controller + .start_attempt( + "integration", + "attempt:terminal-before-outcome", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + + controller + .reconcile_terminal_run_after_restart(TimestampMs::new(10)) + .expect("terminal run reconciliation"); + + let attempt = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .expect("attempt"); + assert_eq!(attempt.status, ParentAttemptStatus::Indeterminate); + assert_eq!( + attempt.cleanup.as_ref().map(|cleanup| cleanup.status), + Some(ParentCleanupStatus::Succeeded) + ); + assert_eq!( + controller.conversation_id.as_deref(), + Some("conversation-1") + ); + controller + .record_baseline_verified("targets:2", TimestampMs::new(11)) + .expect("terminal harness evidence permits baseline refresh"); + let next_attempt = controller + .start_attempt( + "integration", + "attempt:after-terminal-recovery", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:2", + TimestampMs::new(12), + ) + .expect("retry stays on the authoritative conversation"); + assert_ne!(next_attempt, attempt_id); + } + + #[test] + fn verification_selects_real_completed_command_before_receipt_write() { + let mut controller = controller(); + let attempt = controller + .start_attempt( + "integration", + "attempt:commands", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + controller + .observe_command_started( + &attempt, + "command-verify", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(4), + ) + .expect("verification start"); + controller + .observe_command_started( + &attempt, + "command-verify", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(4), + ) + .expect("duplicate start is idempotent"); + controller + .observe_command_finished( + &attempt, + "command-verify", + 0, + Some("passed"), + TimestampMs::new(5), + ) + .expect("verification finish"); + controller + .observe_command_finished( + &attempt, + "command-verify", + 0, + Some("passed"), + TimestampMs::new(5), + ) + .expect("duplicate finish is idempotent"); + controller + .observe_command_started( + &attempt, + "command-receipt", + "write final-verification.json", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(6), + ) + .expect("receipt write start"); + controller + .observe_command_finished(&attempt, "command-receipt", 0, None, TimestampMs::new(7)) + .expect("receipt write finish"); + + let evidence = ParentVerificationEvidence { + schema_version: 1, + run_id: "run-parent".to_owned(), + attempt: 1, + hierarchy_generation: controller.hierarchy_generation, + repository_commits: controller + .targets + .iter() + .map(|(repository_id, target)| { + (repository_id.clone(), target.target_commit.clone()) + }) + .collect(), + command: "cargo test".to_owned(), + command_hash: parent_command_identity("cargo test"), + root: "parent_root".to_owned(), + }; + assert!( + controller + .record_verification_evidence(&attempt, &evidence) + .expect("select observed verification command") + ); + let attempt = controller + .attempts + .iter() + .find(|candidate| candidate.id == attempt) + .expect("attempt receipt"); + assert_eq!(attempt.commands.len(), 2); + assert_eq!(attempt.resources.len(), 4); + assert_eq!(attempt.exit_code, Some(0)); + } + + #[test] + fn verification_uses_exact_command_identity_while_persisting_redacted_text() { + let mut controller = controller(); + let attempt = controller + .start_attempt( + "integration", + "attempt:exact-command", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(10), + ) + .expect("attempt"); + let exact_command = "cargo test token=secret"; + controller + .observe_command_started( + &attempt, + "command-exact", + exact_command, + ParentAttemptRoot::ParentRoot, + TimestampMs::new(11), + ) + .expect("command start"); + controller + .observe_command_finished( + &attempt, + "command-exact", + 0, + Some("passed"), + TimestampMs::new(12), + ) + .expect("command finish"); + let receipt = controller + .attempts + .last() + .expect("attempt") + .commands + .last() + .expect("command"); + assert_ne!(receipt.command, exact_command); + assert_eq!(receipt.command_hash, parent_command_identity(exact_command)); + + let evidence = ParentVerificationEvidence { + schema_version: 1, + run_id: "run-parent".to_owned(), + attempt: 1, + hierarchy_generation: controller.hierarchy_generation, + repository_commits: controller + .targets + .iter() + .map(|(repository_id, target)| { + (repository_id.clone(), target.target_commit.clone()) + }) + .collect(), + command: redact_runtime_diagnostic(exact_command), + command_hash: parent_command_identity(exact_command), + root: "parent_root".to_owned(), + }; + assert!( + controller + .record_verification_evidence(&attempt, &evidence) + .expect("exact command hash selects the observed command") + ); + } + + #[test] + fn command_events_before_the_current_attempt_are_ignored() { + let mut controller = controller(); + let attempt = controller + .start_attempt( + "integration", + "attempt:current-turn", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(10), + ) + .expect("attempt"); + controller + .observe_command_started( + &attempt, + "command-old-turn", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(9), + ) + .expect("stale start is ignored"); + controller + .observe_command_finished( + &attempt, + "command-old-turn", + 0, + Some("old completion"), + TimestampMs::new(9), + ) + .expect("stale completion is ignored"); + assert!( + controller + .attempts + .last() + .expect("attempt") + .commands + .is_empty() + ); + } + + #[test] + fn resources_and_logs_are_bounded_and_attributable() { + let mut controller = controller(); + let first = controller + .start_attempt( + "integration", + "attempt:1", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("first"); + controller + .allocate_resource(&first, "port", "24001", TimestampMs::new(3)) + .expect("port"); + assert!(matches!( + controller.allocate_resource(&first, "port", "24001", TimestampMs::new(3)), + Err(ParentIntegrationError::ResourceCollision { .. }) + )); + controller + .append_log(&first, &"x".repeat(MAX_LOG_BYTES + 10)) + .expect("log"); + let attempt = controller + .attempts + .iter() + .find(|attempt| attempt.id == first) + .expect("attempt"); + assert_eq!(attempt.bounded_log.len(), MAX_LOG_BYTES); + assert!(attempt.log_truncated); + + controller + .release_resource(&first, "port", "24001", true, None, TimestampMs::new(4)) + .expect("release port"); + assert!( + controller + .allocate_resource(&first, "port", "24001", TimestampMs::new(5)) + .is_ok() + ); + controller + .release_resource( + &first, + "port", + "24001", + false, + Some("still listening".to_owned()), + TimestampMs::new(6), + ) + .expect("record failed cleanup"); + assert!(matches!( + controller.allocate_resource(&first, "port", "24001", TimestampMs::new(7)), + Err(ParentIntegrationError::ResourceCollision { .. }) + )); + } + + #[test] + fn attempt_ids_remain_monotonic_after_retention() { + let mut controller = controller(); + controller.next_attempt_sequence = 64; + let attempt = controller + .start_attempt( + "integration", + "attempt:65", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + assert_eq!(attempt, "parent-attempt-65"); + assert_eq!(controller.next_attempt_sequence, 65); + } + + #[test] + fn admission_idempotency_survives_transition_history_compaction() { + let mut controller = controller(); + for sequence in 1..=(MAX_TRANSITIONS / 2 + 8) { + let attempt = controller + .start_attempt( + format!("integration {sequence}"), + format!("attempt:{sequence}"), + ParentAttemptRoot::ParentRoot, + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(sequence as u64 * 3), + ) + .expect("attempt"); + controller + .finish_attempt( + &attempt, + ParentAttemptStatus::Failed, + Some(1), + ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: TimestampMs::new(sequence as u64 * 3 + 1), + detail: None, + }, + TimestampMs::new(sequence as u64 * 3 + 1), + ) + .expect("finish attempt"); + controller + .prepare_retry(&attempt, "retry", TimestampMs::new(sequence as u64 * 3 + 1)) + .expect("prepare retry"); + controller + .record_baseline_verified("targets:1", TimestampMs::new(sequence as u64 * 3 + 2)) + .expect("baseline"); + } + assert_eq!(controller.transitions.len(), MAX_TRANSITIONS); + assert!( + !controller + .transitions + .iter() + .any(|transition| { transition.idempotency_key.ends_with(":children") }) + ); + + let mut restored: ParentIntegrationController = serde_json::from_value( + serde_json::to_value(&controller).expect("serialize controller"), + ) + .expect("restore controller"); + let state_version = restored.state_version; + restored + .admit("hierarchy:7", TimestampMs::new(10_000)) + .expect("persisted admission remains idempotent"); + assert_eq!(restored.state_version, state_version); + assert_eq!( + restored.admission_input_version.as_deref(), + Some("hierarchy:7") + ); + } + + #[test] + fn repository_neutral_parent_can_complete_with_no_checkout_targets() { + let mut controller = + ParentIntegrationController::new(IssueId::new("parent-empty").expect("parent id"), 1) + .expect("controller"); + controller + .admit("hierarchy:1", TimestampMs::new(1)) + .expect("admit"); + controller + .record_workspace_prepared([], "targets:empty", TimestampMs::new(2)) + .expect("empty parent workspace"); + let attempt = controller + .start_attempt( + "repository-neutral verification", + "attempt:empty", + ParentAttemptRoot::ParentRoot, + "conversation-empty", + 100, + "targets:empty", + TimestampMs::new(3), + ) + .expect("attempt"); + finish_passed(&mut controller, &attempt); + controller + .complete(&attempt, "targets:empty", TimestampMs::new(5)) + .expect("empty target set is verified by the runtime command"); + + assert_eq!(controller.state, ParentIntegrationState::Completed); + assert!( + controller + .final_evidence + .as_ref() + .expect("final evidence") + .repository_commits + .is_empty() + ); + } + + #[test] + fn unchanged_targets_refresh_again_before_a_retry_attempt() { + let mut controller = controller(); + let first = controller + .start_attempt( + "integration", + "attempt:first", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(3), + ) + .expect("first attempt"); + controller + .finish_attempt( + &first, + ParentAttemptStatus::Failed, + Some(1), + ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: TimestampMs::new(4), + detail: None, + }, + TimestampMs::new(4), + ) + .expect("failed attempt"); + controller + .prepare_retry(&first, "retry", TimestampMs::new(5)) + .expect("retry transition"); + controller + .record_baseline_verified("targets:1", TimestampMs::new(6)) + .expect("refresh unchanged targets"); + + assert!( + controller + .start_attempt( + "integration retry", + "attempt:second", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(7), + ) + .is_ok() + ); + } + + #[test] + fn completed_intermediate_parent_keeps_checkout_evidence_for_ancestor() { + let mut controller = controller(); + let attempt = controller + .start_attempt( + "integration", + "attempt:1", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 30_000, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + finish_passed(&mut controller, &attempt); + controller + .complete(&attempt, "targets:1", TimestampMs::new(5)) + .expect("complete"); + let encoded = serde_json::to_value(&controller).expect("encode"); + let recovered: ParentIntegrationController = + serde_json::from_value(encoded).expect("decode"); + assert_eq!(recovered.targets.len(), 3); + assert_eq!(recovered.final_evidence, controller.final_evidence); + } +} diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 02b3cd801..450439cf5 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -1,6 +1,7 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, future::Future, + path::{Component, Path, PathBuf}, time::Duration, }; @@ -18,7 +19,9 @@ use crate::opensymphony_domain::{ }; use crate::opensymphony_gateway_schema::capability::{HarnessCapability, HarnessKind}; use crate::opensymphony_workflow::{ResolvedWorkflow, RoutingConfig}; -use crate::opensymphony_workspace::{checkout_workspace_key, sanitize_workspace_key}; +use crate::opensymphony_workspace::{ + checkout_workspace_key, redact_runtime_diagnostic, sanitize_workspace_key, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -31,7 +34,9 @@ use tracing::{debug, warn}; use super::filter_issues_for_dispatch; use super::{ DurableOrchestratorState, HierarchyBlockedReason, HierarchySnapshot, LeaseRecord, - LeaseResource, ParentEligibilityEvidence, + LeaseResource, ParentAttemptRoot, ParentAttemptStatus, ParentCleanupReceipt, + ParentCleanupStatus, ParentEligibilityEvidence, ParentIntegrationController, + ParentIntegrationError, ParentRepositoryTarget, }; const DISABLED_STALL_TIMEOUT_MS: u64 = u64::MAX / 4; @@ -227,10 +232,11 @@ pub struct WorkerStartRequest { pub workspace: WorkspaceRecord, pub run: RunAttempt, pub route: HarnessRouteDecision, - /// The issue was restored from durable process-recovery state. A scoped - /// memory grant is process-local, so a reused conversation must be - /// replaced once before the next worker prompt installs the new bearer. + /// The issue was restored from durable process-recovery state. pub memory_grant_registry_recovered: bool, + /// A parent retry must attach this durable controller-owned conversation. + /// The backend rejects a missing or different manifest before session launch. + pub expected_parent_conversation_id: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -314,6 +320,7 @@ pub struct WorkerInterruptAcknowledgement { struct WorkerMetadata { issue_id: IssueId, harness_kind: Option, + dry_run: bool, } impl WorkerMetadata { @@ -321,8 +328,14 @@ impl WorkerMetadata { Self { issue_id, harness_kind, + dry_run: false, } } + + fn with_dry_run(mut self, dry_run: bool) -> Self { + self.dry_run = dry_run; + self + } } #[allow(async_fn_in_trait)] @@ -426,6 +439,14 @@ pub trait WorkspaceBackend { Ok(false) } + async fn parent_workspace_targets( + &mut self, + _issue: &NormalizedIssue, + _workspace: &WorkspaceRecord, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + async fn recover_retry_exhaustion( &mut self, ) -> Result, Self::Error> { @@ -574,6 +595,8 @@ pub enum SchedulerError { RetryCalculation(#[from] RetryCalculationError), #[error(transparent)] Identifier(#[from] IdentifierError), + #[error(transparent)] + ParentIntegration(#[from] ParentIntegrationError), } pub struct Scheduler { @@ -990,7 +1013,7 @@ where pub async fn replan_parent( &mut self, parent_id: &IssueId, - _observed_at: TimestampMs, + observed_at: TimestampMs, ) -> Result { self.load_recovery_state().await?; let Some(snapshot) = self.hierarchy_state.hierarchy.get(parent_id) else { @@ -1023,11 +1046,34 @@ where self.hierarchy_state .rebind_ancestor_leases(parent_id, generation); } + let controller_replaced = generation.is_some_and(|generation| { + self.hierarchy_state + .parent_integrations + .get(parent_id) + .is_some_and(|controller| controller.hierarchy_generation != generation) + }); + if controller_replaced { + self.hierarchy_state.parent_integrations.insert( + parent_id.clone(), + ParentIntegrationController::new( + parent_id.clone(), + generation.expect("controller replacement requires a generation"), + )?, + ); + } self.parent_eligibility_checked_at.remove(parent_id); if let Err(error) = self.persist_orchestrator_state().await { self.hierarchy_state = previous_state; return Err(error); } + if controller_replaced + && let Some(issue) = self + .executions + .get(parent_id) + .map(|execution| execution.issue().clone()) + { + self.reopen_completed_parent_for_new_controller(&issue, observed_at)?; + } Ok(true) } @@ -1612,6 +1658,9 @@ where self.hierarchy_state .run_hierarchy_generations .remove(&normalized.id); + self.hierarchy_state + .parent_integrations + .remove(&normalized.id); if let Some(snapshot) = self.hierarchy_state.hierarchy.remove(&normalized.id) { let removed_child_ids = snapshot .required_child_edges @@ -1644,6 +1693,10 @@ where self.hierarchy_state .hierarchy .insert(normalized.id.clone(), snapshot); + self.hierarchy_state.parent_integrations.insert( + normalized.id.clone(), + ParentIntegrationController::new(normalized.id.clone(), 1)?, + ); if terminal_undispatched { self.hierarchy_state .release_subtree_evidence_for_undispatched_parent_with_reachability( @@ -1674,34 +1727,37 @@ where reachable_child_edges, current_epoch_millis(), ); - let reactivated_parent = self - .hierarchy_state - .hierarchy - .get(&normalized.id) - .is_some_and(|snapshot| { - snapshot.dispatch_claimed() - && !snapshot.has_in_flight_dispatch() - && normalized.state.category == IssueStateCategory::Active - && !self - .executions - .get(&normalized.id) - .is_some_and(|execution| { - matches!( - execution.status(), - SchedulerStatus::Claimed | SchedulerStatus::Running - ) + let parent_waiting_for_tracker_confirmation = + self.parent_waiting_for_tracker_confirmation(&normalized.id); + let reactivated_parent = !parent_waiting_for_tracker_confirmation + && self + .hierarchy_state + .hierarchy + .get(&normalized.id) + .is_some_and(|snapshot| { + snapshot.dispatch_claimed() + && !snapshot.has_in_flight_dispatch() + && normalized.state.category == IssueStateCategory::Active + && !self + .executions + .get(&normalized.id) + .is_some_and(|execution| { + matches!( + execution.status(), + SchedulerStatus::Claimed | SchedulerStatus::Running + ) + }) + && !self.hierarchy_state.leases.iter().any(|lease| { + lease.active() + && normalized.parent_id.is_none() + && (lease.owner == super::LeaseOwner::ancestor(&normalized.id) + || (lease.kind == super::LeaseKind::Review + && lease.owner.id.starts_with(&format!( + "review:{normalized_id}:", + normalized_id = normalized.id + )))) }) - && !self.hierarchy_state.leases.iter().any(|lease| { - lease.active() - && normalized.parent_id.is_none() - && (lease.owner == super::LeaseOwner::ancestor(&normalized.id) - || (lease.kind == super::LeaseKind::Review - && lease.owner.id.starts_with(&format!( - "review:{normalized_id}:", - normalized_id = normalized.id - )))) - }) - }); + }); let reconciliation = if let Some(snapshot) = self.hierarchy_state.hierarchy.get_mut(&normalized.id) { if reactivated_parent { @@ -1741,6 +1797,9 @@ where }; if let Some((generation, current_child_ids, removed_child_ids)) = reconciliation { self.parent_eligibility_checked_at.remove(&normalized.id); + self.hierarchy_state + .terminal_orchestrator_issues + .remove(&normalized.id); let retained_child_ids = current_child_ids .iter() .filter(|child_id| { @@ -1766,11 +1825,77 @@ where .run_hierarchy_generations .insert(child_id.clone(), generation); } + if self + .hierarchy_state + .hierarchy + .get(&normalized.id) + .is_some_and(|snapshot| snapshot.blocked_reason.is_none()) + { + self.hierarchy_state.parent_integrations.insert( + normalized.id.clone(), + ParentIntegrationController::new(normalized.id.clone(), generation)?, + ); + self.reopen_completed_parent_for_new_controller( + &normalized, + TimestampMs::new(current_epoch_millis()), + )?; + } return Ok(true); } + if reactivated_parent { + let generation = self + .hierarchy_state + .hierarchy + .get(&normalized.id) + .map(|snapshot| snapshot.generation); + if let Some(generation) = generation + && self + .hierarchy_state + .hierarchy + .get(&normalized.id) + .is_some_and(|snapshot| snapshot.blocked_reason.is_none()) + { + self.parent_eligibility_checked_at.remove(&normalized.id); + self.hierarchy_state.parent_integrations.insert( + normalized.id.clone(), + ParentIntegrationController::new(normalized.id.clone(), generation)?, + ); + self.reopen_completed_parent_for_new_controller( + &normalized, + TimestampMs::new(current_epoch_millis()), + )?; + } + } Ok(released_terminal_parent_evidence || reactivated_parent) } + fn reopen_completed_parent_for_new_controller( + &mut self, + issue: &NormalizedIssue, + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + if issue.state.category != IssueStateCategory::Active + || !self.executions.get(&issue.id).is_some_and(|execution| { + matches!( + execution.state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::Completed, + .. + } + ) + }) + { + return Ok(()); + } + let mut execution = self + .remove_execution(&issue.id) + .expect("completed parent execution was checked above"); + execution = execution.reopen(observed_at)?; + execution.refresh_issue(issue.clone())?; + self.insert_execution(issue.id.clone(), execution); + Ok(()) + } + fn set_linear_cooldown_from_tracker_error( &mut self, error: &T::Error, @@ -1839,11 +1964,48 @@ where .map(|pending| (pending.issue.id.clone(), pending)) .collect::>(); let mut records = records; + if self + .migrate_legacy_in_flight_parent_controllers(&records, observed_at) + .await? + { + self.persist_orchestrator_state().await?; + } + for record in &records { + if let Some(controller) = self + .hierarchy_state + .parent_integrations + .get_mut(&record.issue.id) + { + if record.recovered_run.is_some() { + controller.reconcile_restart(true, observed_at)?; + } else if record.successful_run || record.completed_run || record.cancelled_run { + controller.reconcile_terminal_run_after_restart(observed_at)?; + } else { + controller.reconcile_restart(false, observed_at)?; + } + self.hierarchy_state_dirty = true; + } + } + for controller in self.hierarchy_state.parent_integrations.values_mut() { + let has_unlaunched_attempt = controller.attempts.iter().any(|attempt| { + attempt.status == ParentAttemptStatus::Running + && attempt.conversation_id.is_none() + && attempt.commands.is_empty() + && attempt.resources.is_empty() + }); + if has_unlaunched_attempt { + controller.reconcile_restart(false, observed_at)?; + self.hierarchy_state_dirty = true; + } + } + if self.hierarchy_state_dirty { + self.persist_orchestrator_state().await?; + } // Recovery manifests intentionally do not persist a second hierarchy // identity. Hydrate the parent edge from the provider's full issue - // detail before terminal cleanup can release an intermediate parent's - // ancestor leases; the higher ancestor may be outside the active - // project scan while the intermediate issue is terminal. + // detail so retained intermediate-parent leases preserve the higher + // ancestor owner, which may be outside the active project scan while + // the intermediate issue is terminal. let recovered_parent_ids = tracker_snapshot .active .iter() @@ -2265,7 +2427,12 @@ where .remove_execution(&issue_id) .expect("active recovery execution should be present"); let already_exhausted = retry_exhausted_release(&execution); - if self.retry_limit_reached(record.normal_retry_count) { + if self.parent_waiting_for_tracker_confirmation(&issue_id) { + self.insert_execution( + issue_id.clone(), + execution.release(observed_at, ReleaseReason::Completed, None)?, + ); + } else if self.retry_limit_reached(record.normal_retry_count) { let mut execution = if already_exhausted { execution } else { @@ -2358,7 +2525,12 @@ where } if tracker_snapshot.contains_terminal(issue_id.as_str()) { - if record.successful_run && !record.had_in_flight_run { + let issue_has_children = tracker_snapshot + .terminal_issue(&issue_id) + .map(|issue| !issue.sub_issues.is_empty()) + .unwrap_or(!record.issue.sub_issues.is_empty()); + let parent_finalized = self.parent_finalized_success(&issue_id, issue_has_children); + if record.successful_run && !record.had_in_flight_run && parent_finalized { self.record_terminal_orchestrator_success(&issue_id).await?; } if let Some(recovered_run) = record.recovered_run.as_ref() { @@ -2378,49 +2550,16 @@ where retry_records.push(record); continue; } - if record.had_in_flight_run - && self.hierarchy_state.hierarchy.contains_key(&issue_id) - { - let nested_child_ids = self - .hierarchy_state - .hierarchy - .get(&issue_id) - .map(|snapshot| { - snapshot - .required_child_edges - .iter() - .filter(|edge| { - edge.required - && self - .hierarchy_state - .hierarchy - .contains_key(&edge.child_id) - }) - .map(|edge| edge.child_id.clone()) - .collect::>() - }) - .unwrap_or_default(); - self.hierarchy_state - .release_leaf_leases_for_parent(&issue_id, observed_at.as_u64()); - self.hierarchy_state.release_ancestor_leases_for_children( - &nested_child_ids, - observed_at.as_u64(), - ); - self.persist_orchestrator_state().await?; - } } - if let Err(error) = self - .release_parent_leases_after_finalization( - &issue_id, - record.issue.parent_id.as_ref(), - observed_at, - ) - .await - { - retry_records.push(record); - retry_records.extend(records.iter().skip(record_index + 1).cloned()); - self.pending_recovery = Some(retry_records); - return Err(error); + if !parent_finalized { + let mut execution = IssueExecution::new(record.issue.clone(), observed_at); + execution.attach_workspace(record.workspace.clone())?; + self.insert_execution( + issue_id.clone(), + execution.release(observed_at, ReleaseReason::TrackerTerminal, None)?, + ); + self.terminal_child_failure_ids.insert(issue_id.clone()); + continue; } if let Err(error) = self .retain_terminal_child_lease_for_workspace(&record.issue, &record.workspace) @@ -2460,7 +2599,9 @@ where && !record.cancelled_run && self.retry_limit_reached(record.normal_retry_count) && self.workspace.retain_failed_workspaces(); - let cleanup_result = if retain_failed { + let parent_workspace = + self.is_parent_integration_workspace(&issue_id, issue_has_children); + let cleanup_result = if parent_workspace || retain_failed { Ok(()) } else if !record.successful_run && !record.cancelled_run @@ -3220,6 +3361,78 @@ where Ok(()) } + /// Migrate runs written before parent controllers were persisted. The + /// recovered run manifest and parent workspace envelope are the durable + /// proof that launch already happened, so recreate the missing controller + /// and bind it to that exact conversation before backend reattachment. + async fn migrate_legacy_in_flight_parent_controllers( + &mut self, + records: &[RecoveryRecord], + observed_at: TimestampMs, + ) -> Result { + let mut changed = false; + for record in records { + if !record.had_in_flight_run { + continue; + } + let Some(recovered_run) = record.recovered_run.as_ref() else { + continue; + }; + if self + .hierarchy_state + .parent_integrations + .contains_key(&record.issue.id) + { + continue; + } + let Some(snapshot) = self + .hierarchy_state + .hierarchy + .get(&record.issue.id) + .cloned() + else { + continue; + }; + if snapshot.required_child_edges.is_empty() && record.issue.sub_issues.is_empty() { + continue; + } + + let targets = self + .workspace + .parent_workspace_targets(&record.issue, &record.workspace) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })?; + let started_at = self + .hierarchy_state + .run_started_at_by_issue + .get(&record.issue.id) + .copied() + .unwrap_or(observed_at); + let admission_input_version = parent_input_version(&snapshot); + let targets_input_version = parent_targets_input_version(snapshot.generation, &targets); + let mut controller = + ParentIntegrationController::new(record.issue.id.clone(), snapshot.generation)?; + controller.admit(&admission_input_version, started_at)?; + controller.record_workspace_prepared(targets, &targets_input_version, started_at)?; + controller.start_attempt( + "parent integration harness run", + format!("parent-run:{}", recovered_run.worker_id), + ParentAttemptRoot::ParentRoot, + recovered_run.conversation.conversation_id.as_str(), + effective_stall_timeout(self.config.stall_timeout_ms).as_u64(), + targets_input_version, + started_at, + )?; + self.hierarchy_state + .parent_integrations + .insert(record.issue.id.clone(), controller); + changed = true; + } + Ok(changed) + } + async fn restore_recovered_run( &mut self, issue_id: &IssueId, @@ -3301,12 +3514,21 @@ where harness_kind.as_deref(), )?; execution = execution.claim(run.clone())?; + let expected_parent_conversation_id = (!recovery_issue.sub_issues.is_empty()) + .then(|| { + self.hierarchy_state + .parent_integrations + .get(issue_id) + .and_then(|controller| controller.conversation_id.clone()) + }) + .flatten(); let start_request = WorkerStartRequest { issue: recovery_issue, workspace: workspace.clone(), run: run.clone(), route: route.clone(), memory_grant_registry_recovered: self.recovered_memory_issue_ids.contains(issue_id), + expected_parent_conversation_id, }; self.remove_execution(issue_id); let launch = match self.worker.recover_worker(start_request).await { @@ -3337,7 +3559,7 @@ where execution = execution.start_running( observed_at, effective_stall_timeout(self.config.stall_timeout_ms), - Some(launch.conversation), + Some(launch.conversation.clone()), )?; execution.record_turn_started(observed_at)?; if let Some(reason) = interrupt_reason { @@ -3365,13 +3587,110 @@ where issue_id.clone(), harness_kind .filter(|kind| !kind.trim().is_empty()) - .or(Some(route.harness_kind)), - ), + .or(Some(route.harness_kind.clone())), + ) + .with_dry_run(route.dry_run), ); + if !execution.issue().sub_issues.is_empty() && !route.dry_run { + let conversation_id = launch.conversation.conversation_id.to_string(); + let previous_state = self.hierarchy_state.clone(); + let attachment_result = self + .hierarchy_state + .parent_integrations + .get(issue_id) + .and_then(|controller| controller.current_attempt_id()) + .map(str::to_owned) + .ok_or_else(|| SchedulerError::Workspace { + detail: "recovered parent launch has no durable attempt intent".to_owned(), + }) + .and_then(|attempt_id| { + self.hierarchy_state + .parent_integrations + .get_mut(issue_id) + .ok_or_else(|| SchedulerError::Workspace { + detail: "recovered parent launch has no durable integration controller" + .to_owned(), + })? + .attach_attempt_conversation(&attempt_id, &conversation_id) + .map_err(SchedulerError::from) + }); + if let Err(error) = attachment_result { + self.hierarchy_state = previous_state; + execution = self + .retain_recovered_execution_after_attachment_failure( + issue_id, + execution, + &run, + observed_at, + &error.to_string(), + ) + .await?; + self.insert_execution(issue_id.clone(), execution); + return Err(error); + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + execution = self + .retain_recovered_execution_after_attachment_failure( + issue_id, + execution, + &run, + observed_at, + &error.to_string(), + ) + .await?; + self.insert_execution(issue_id.clone(), execution); + return Err(error); + } + } self.insert_execution(issue_id.clone(), execution); Ok(()) } + async fn retain_recovered_execution_after_attachment_failure( + &mut self, + issue_id: &IssueId, + mut execution: IssueExecution, + run: &RunAttempt, + observed_at: TimestampMs, + detail: &str, + ) -> Result { + match self + .abort_worker( + &mut execution, + run, + WorkerAbortReason::TrackerInactive, + observed_at, + ) + .await + { + Ok(true) => { + let outcome = WorkerOutcomeRecord::from_run( + run, + WorkerOutcomeKind::Failed, + observed_at, + Some("recovered parent attachment was not durable".to_owned()), + Some(detail.to_owned()), + ); + execution = execution.release( + observed_at, + ReleaseReason::TrackerInactive, + Some(outcome), + )?; + } + Ok(false) => warn!( + issue_id = %issue_id, + "retaining recovered parent worker after durable attachment failure until its stop is acknowledged" + ), + Err(abort_error) => warn!( + issue_id = %issue_id, + error = %abort_error, + "retaining recovered parent worker after durable attachment failure because abort failed" + ), + } + Ok(execution) + } + async fn retry_recovered_interrupt( &mut self, issue_id: &IssueId, @@ -3742,32 +4061,95 @@ where } }; - if let Some(normal_retry_count) = self - .executions - .get(&issue_id) - .and_then(IssueExecution::retry) - .map(|retry| retry.normal_retry_count) - { - // Keep the durable pending-retry marker intact until the - // worker's start_run preparation writes the replacement run - // manifest. A crash before start_workers must recover the - // queued retry rather than an advanced, unqueued count. - if let Err(error) = self + if !normalized.sub_issues.is_empty() { + let targets = match self .workspace - .persist_retry_count(&workspace, normal_retry_count) + .parent_workspace_targets(&normalized, &workspace) .await { - let error = self - .clear_parent_dispatch_intent_after_preparation_failure( - &issue_id, - SchedulerError::Workspace { - detail: error.to_string(), - }, - ) - .await; - if first_error.is_none() { - first_error = Some(error); - } + Ok(targets) => targets, + Err(error) => { + let error = self + .clear_parent_dispatch_intent_after_preparation_failure( + &issue_id, + SchedulerError::Workspace { + detail: error.to_string(), + }, + ) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + }; + let input_version = parent_targets_input_version( + self.hierarchy_state + .hierarchy + .get(&issue_id) + .map_or(0, |snapshot| snapshot.generation), + &targets, + ); + let previous_state = self.hierarchy_state.clone(); + let result = self + .hierarchy_state + .parent_integrations + .get_mut(&issue_id) + .ok_or_else(|| SchedulerError::Workspace { + detail: "parent workspace has no durable integration controller".to_owned(), + })? + .record_workspace_prepared(targets, &input_version, observed_at); + if let Err(error) = result { + self.hierarchy_state = previous_state; + let error = self + .clear_parent_dispatch_intent_after_preparation_failure( + &issue_id, + error.into(), + ) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + } + + if let Some(normal_retry_count) = self + .executions + .get(&issue_id) + .and_then(IssueExecution::retry) + .map(|retry| retry.normal_retry_count) + { + // Keep the durable pending-retry marker intact until the + // worker's start_run preparation writes the replacement run + // manifest. A crash before start_workers must recover the + // queued retry rather than an advanced, unqueued count. + if let Err(error) = self + .workspace + .persist_retry_count(&workspace, normal_retry_count) + .await + { + let error = self + .clear_parent_dispatch_intent_after_preparation_failure( + &issue_id, + SchedulerError::Workspace { + detail: error.to_string(), + }, + ) + .await; + if first_error.is_none() { + first_error = Some(error); + } continue; } } @@ -3842,6 +4224,7 @@ where continue; } let execution_before_claim = execution.clone(); + let unclaimed_execution = execution_before_claim.clone(); execution = match execution.claim(run.clone()) { Ok(execution) => execution, Err(error) => { @@ -3871,8 +4254,66 @@ where memory_grant_registry_recovered: self .recovered_memory_issue_ids .contains(&issue_id), + expected_parent_conversation_id: (!normalized.sub_issues.is_empty()) + .then(|| { + self.hierarchy_state + .parent_integrations + .get(&issue_id) + .and_then(|controller| controller.conversation_id.clone()) + }) + .flatten(), }; + if !normalized.sub_issues.is_empty() && !start_request.route.dry_run { + let previous_state = self.hierarchy_state.clone(); + let input_version = self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .map(parent_controller_input_version) + .unwrap_or_default(); + let result = self + .hierarchy_state + .parent_integrations + .get_mut(&issue_id) + .ok_or_else(|| SchedulerError::Workspace { + detail: "parent launch has no durable integration controller".to_owned(), + })? + .start_attempt_intent( + "parent integration harness run", + format!("parent-run:{}", claimed_run.worker_id), + ParentAttemptRoot::ParentRoot, + effective_stall_timeout(self.config.stall_timeout_ms).as_u64(), + input_version, + observed_at, + ); + if let Err(error) = result { + self.hierarchy_state = previous_state; + self.insert_execution(issue_id.clone(), unclaimed_execution); + let error = self + .clear_parent_dispatch_intent_after_preparation_failure( + &issue_id, + error.into(), + ) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + self.insert_execution(issue_id.clone(), unclaimed_execution); + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + } + *planned_running_by_state.entry(state_key).or_default() += 1; pending_launches.push((issue_id, execution, claimed_run, start_request)); } @@ -3894,6 +4335,26 @@ where Ok(launch) => { self.recovered_memory_issue_ids.remove(&issue_id); let started_at = launch.started_at.unwrap_or(observed_at); + if !execution.issue().sub_issues.is_empty() && !start_request.route.dry_run { + let conversation_id = launch.conversation.conversation_id.to_string(); + let attempt_id = self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .and_then(|controller| controller.current_attempt_id()) + .map(str::to_owned) + .ok_or_else(|| SchedulerError::Workspace { + detail: "parent launch has no durable attempt intent".to_owned(), + })?; + self.hierarchy_state + .parent_integrations + .get_mut(&issue_id) + .ok_or_else(|| SchedulerError::Workspace { + detail: "parent launch has no durable integration controller" + .to_owned(), + })? + .attach_attempt_conversation(&attempt_id, &conversation_id)?; + } execution = execution.start_running( observed_at, effective_stall_timeout(self.config.stall_timeout_ms), @@ -3905,7 +4366,8 @@ where WorkerMetadata::new( issue_id.clone(), Some(start_request.route.harness_kind), - ), + ) + .with_dry_run(start_request.route.dry_run), ); let run_start_changed = self .hierarchy_state @@ -4009,6 +4471,156 @@ where first_error.map_or(Ok(()), Err) } + async fn record_parent_worker_outcome( + &mut self, + issue_id: &IssueId, + execution: &IssueExecution, + outcome: &mut WorkerOutcomeRecord, + ) -> Result<(), SchedulerError> { + let previous_state = self.hierarchy_state.clone(); + let merging_continuation = tracker_merging_interrupt_cancelled(execution, outcome); + let Some(controller) = self.hierarchy_state.parent_integrations.get_mut(issue_id) else { + return Ok(()); + }; + let Some(attempt_id) = controller.current_attempt_id().map(str::to_owned) else { + return Ok(()); + }; + let launch_never_attached = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .is_some_and(|attempt| attempt.conversation_id.is_none()); + let deadline_reached = controller + .current_attempt_deadline() + .is_some_and(|deadline| outcome.finished_at.as_u64() >= deadline.as_u64()); + if execution + .interrupt() + .is_some_and(|interrupt| interrupt.status == HarnessInterruptStatus::Acknowledged) + { + controller.observe_harness_stopped( + &attempt_id, + "harness interrupt reconciled a stopped state", + outcome.finished_at, + )?; + } + let verification_passed = if deadline_reached { + controller.append_log( + &attempt_id, + "worker outcome arrived at or after the absolute parent attempt deadline", + )?; + false + } else { + match outcome.parent_verification.as_ref() { + Some(evidence) => { + match controller.record_verification_evidence(&attempt_id, evidence) { + Ok(passed) => passed, + Err(error) => { + controller.append_log( + &attempt_id, + &format!("verification receipt rejected: {error}"), + )?; + false + } + } + } + None => false, + } + }; + enforce_parent_outcome_trust(outcome, deadline_reached, verification_passed); + if matches!( + outcome.outcome, + WorkerOutcomeKind::Succeeded | WorkerOutcomeKind::Failed | WorkerOutcomeKind::Cancelled + ) { + controller.observe_harness_stopped( + &attempt_id, + "terminal worker outcome reconciled the harness turn as stopped", + outcome.finished_at, + )?; + } + let observed_cleanup = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .and_then(|attempt| attempt.cleanup.clone()); + let status = match outcome.outcome { + WorkerOutcomeKind::Succeeded if verification_passed => ParentAttemptStatus::Passed, + WorkerOutcomeKind::Succeeded | WorkerOutcomeKind::Failed => ParentAttemptStatus::Failed, + WorkerOutcomeKind::TimedOut | WorkerOutcomeKind::Stalled => { + ParentAttemptStatus::TimedOut + } + WorkerOutcomeKind::Cancelled => ParentAttemptStatus::Canceled, + WorkerOutcomeKind::Detached | WorkerOutcomeKind::CancelFailed => { + ParentAttemptStatus::Indeterminate + } + }; + let cleanup = observed_cleanup.unwrap_or_else(|| ParentCleanupReceipt { + status: if launch_never_attached { + ParentCleanupStatus::Succeeded + } else { + ParentCleanupStatus::Pending + }, + occurred_at: outcome.finished_at, + detail: Some(if launch_never_attached { + "worker launch failed before a parent command could start".to_owned() + } else { + "parent command teardown receipt was not available".to_owned() + }), + }); + let exit_code = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .and_then(|attempt| attempt.exit_code); + controller.finish_attempt(&attempt_id, status, exit_code, cleanup, outcome.finished_at)?; + let input_version = parent_controller_input_version(controller); + if status == ParentAttemptStatus::Passed + && execution.issue().state.category == IssueStateCategory::Terminal + { + controller.complete(&attempt_id, &input_version, outcome.finished_at)?; + } else if status == ParentAttemptStatus::Passed { + // The harness evidence is final, but only the tracker can prove + // that the parent reached its terminal workflow state. Preserve + // this passed attempt so a later successful tracker refresh can + // finalize it without rerunning or losing its exact receipts. + } else if merging_continuation { + controller.prepare_retry( + &attempt_id, + "tracker merging superseded human-review polling; refresh before continuing", + outcome.finished_at, + )?; + } else if status == ParentAttemptStatus::Indeterminate + || (status == ParentAttemptStatus::Canceled + && (execution.issue().state.category != IssueStateCategory::Active + || acknowledged_operator_cancel_terminal(execution, outcome))) + { + controller.cancel( + outcome + .summary + .clone() + .unwrap_or_else(|| "parent integration harness was canceled".to_owned()), + &input_version, + status == ParentAttemptStatus::Canceled, + outcome.finished_at, + )?; + } else { + controller.prepare_retry( + &attempt_id, + outcome + .error + .as_deref() + .or(outcome.summary.as_deref()) + .unwrap_or("parent integration turn finished before terminal tracker state"), + outcome.finished_at, + )?; + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + self.hierarchy_state_dirty = true; + return Err(error); + } + Ok(()) + } + async fn clear_parent_dispatch_intent_after_preparation_failure( &mut self, issue_id: &IssueId, @@ -4058,6 +4670,42 @@ where else { continue; }; + let parent_log = format!( + "{}: {}{}", + event_kind.as_deref().unwrap_or("runtime_event"), + summary.as_deref().unwrap_or_default(), + payload + .as_ref() + .map(|payload| format!(" {payload}")) + .unwrap_or_default() + ); + let parent_workspace_path = self + .executions + .get(&issue_id) + .and_then(IssueExecution::workspace) + .map(|workspace| workspace.path.clone()); + if let Some(controller) = + self.hierarchy_state.parent_integrations.get_mut(&issue_id) + && let Some(attempt_id) = controller.current_attempt_id().map(str::to_owned) + { + if let Err(error) = observe_parent_command_event( + controller, + &attempt_id, + parent_workspace_path.as_deref(), + observed_at, + event_id.as_deref(), + event_kind.as_deref(), + payload.as_ref(), + ) { + controller.append_log( + &attempt_id, + &format!("runtime command evidence rejected: {error}"), + )?; + } + controller + .append_log(&attempt_id, &redact_runtime_diagnostic(&parent_log))?; + self.hierarchy_state_dirty = true; + } if let Some(execution) = self.executions.get_mut(&issue_id) { execution.observe_runtime_event( observed_at, @@ -4076,6 +4724,15 @@ where let Some(execution) = self.remove_execution(&issue_id) else { continue; }; + if metadata.dry_run { + let execution = execution.release( + outcome.finished_at, + ReleaseReason::Completed, + Some(outcome), + )?; + self.insert_execution(issue_id, execution); + continue; + } let finished_at = outcome.finished_at; let original_execution = execution.clone(); let finished_outcome = outcome.clone(); @@ -4161,6 +4818,13 @@ where } } + if self.hierarchy_state_dirty + && let Err(error) = self.persist_orchestrator_state().await + && first_error.is_none() + { + first_error = Some(error); + } + first_error.map_or(Ok(()), Err) } @@ -4213,17 +4877,25 @@ where let stalled = self .executions .iter() - .filter_map(|(issue_id, execution)| match execution.state() { - crate::opensymphony_domain::SchedulerState::Running { stall, .. } - if stall.stalled_at <= observed_at => - { - Some(issue_id.clone()) + .filter_map(|(issue_id, execution)| { + let absolute_timeout = self + .hierarchy_state + .parent_integrations + .get(issue_id) + .and_then(ParentIntegrationController::current_attempt_deadline) + .is_some_and(|deadline| deadline <= observed_at); + match execution.state() { + crate::opensymphony_domain::SchedulerState::Running { stall, .. } + if stall.stalled_at <= observed_at || absolute_timeout => + { + Some((issue_id.clone(), absolute_timeout)) + } + _ => None, } - _ => None, }) .collect::>(); - for issue_id in stalled { + for (issue_id, absolute_timeout) in stalled { let Some(mut execution) = self.remove_execution(&issue_id) else { continue; }; @@ -4249,14 +4921,24 @@ where } let outcome = WorkerOutcomeRecord::from_run( &run, - if remote_stopped { + if absolute_timeout { + WorkerOutcomeKind::TimedOut + } else if remote_stopped { WorkerOutcomeKind::Stalled } else { WorkerOutcomeKind::Detached }, observed_at, - Some("worker exceeded the configured stall timeout".to_string()), - Some("scheduler stall timeout reached".to_string()), + Some(if absolute_timeout { + "parent integration attempt exceeded its absolute timeout".to_owned() + } else { + "worker exceeded the configured stall timeout".to_owned() + }), + Some(if absolute_timeout { + "parent integration command deadline reached".to_owned() + } else { + "scheduler stall timeout reached".to_owned() + }), ); execution = self .resolve_finished_execution(execution, outcome, observed_at) @@ -4336,7 +5018,10 @@ where return Ok(()); }; - execution.refresh_issue(issue)?; + if let Err(error) = execution.refresh_issue(issue) { + self.insert_execution(issue_id, execution); + return Err(error.into()); + } let abort_requested = abort_reason.is_some(); let mut remote_stopped = true; if let Some(run) = execution.current_run().cloned() @@ -4361,6 +5046,93 @@ where self.insert_execution(issue_id, execution); return Ok(()); } + let previous_parent_state = self.hierarchy_state.clone(); + let parent_update = (|| -> Result { + let Some(controller) = self.hierarchy_state.parent_integrations.get_mut(&issue_id) + else { + return Ok(false); + }; + if execution.issue().state.category == IssueStateCategory::Terminal + && !controller.state.terminal() + && controller.current_attempt_id().is_none() + && let Some(attempt_id) = controller + .attempts + .iter() + .rev() + .find(|attempt| attempt.status == ParentAttemptStatus::Passed) + .map(|attempt| attempt.id.clone()) + { + let input_version = parent_controller_input_version(controller); + match controller.complete(&attempt_id, &input_version, observed_at) { + Ok(()) => return Ok(true), + Err(ParentIntegrationError::FinalVerificationIncomplete) => { + // A legacy or malformed Passed marker is insufficient + // to release the parent. Keep its controller and + // leases intact for operator-visible recovery. + return Ok(false); + } + Err(error) => return Err(error.into()), + } + } + let Some(attempt_id) = controller.current_attempt_id().map(str::to_owned) else { + return Ok(false); + }; + if execution + .interrupt() + .is_some_and(|interrupt| interrupt.status == HarnessInterruptStatus::Acknowledged) + { + controller.observe_harness_stopped( + &attempt_id, + "harness interrupt reconciled a stopped state", + observed_at, + )?; + } + let observed_cleanup = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .and_then(|attempt| attempt.cleanup.clone()); + let observed_exit_code = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .and_then(|attempt| attempt.exit_code); + controller.finish_attempt( + &attempt_id, + ParentAttemptStatus::Canceled, + observed_exit_code, + observed_cleanup.unwrap_or_else(|| ParentCleanupReceipt { + status: ParentCleanupStatus::Pending, + occurred_at: observed_at, + detail: Some(format!( + "scheduler release acknowledged harness stop but has no command teardown receipt: {reason:?}" + )), + }), + observed_at, + )?; + let input_version = parent_controller_input_version(controller); + controller.cancel( + format!("scheduler released parent integration: {reason:?}"), + &input_version, + true, + observed_at, + )?; + Ok(true) + })(); + let parent_changed = match parent_update { + Ok(changed) => changed, + Err(error) => { + self.hierarchy_state = previous_parent_state; + self.insert_execution(issue_id, execution); + return Err(error); + } + }; + if parent_changed && let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_parent_state; + self.hierarchy_state_dirty = true; + self.insert_execution(issue_id, execution); + return Err(error); + } self.workspace .revoke_issue_resources(execution.issue().identifier.as_str()); let was_retry_exhausted = retry_exhausted_release(&execution); @@ -4394,29 +5166,24 @@ where && execution .last_worker_outcome() .is_some_and(|outcome| outcome.outcome == WorkerOutcomeKind::Succeeded)); - if successful_terminal_outcome { + let parent_finalized = + self.parent_finalized_success(&issue_id, !execution.issue().sub_issues.is_empty()); + if successful_terminal_outcome && parent_finalized { self.record_terminal_orchestrator_success(&issue_id).await?; } - if cleanup_terminal && let Err(error) = self.retain_terminal_child_lease(&execution).await { - self.insert_execution(issue_id, execution); - return Err(error); - } - if matches!( - reason, - ReleaseReason::TrackerTerminal | ReleaseReason::Completed - ) && let Err(error) = self - .release_parent_leases_after_finalization( - &issue_id, - execution.issue().parent_id.as_ref(), - observed_at, - ) - .await + if cleanup_terminal + && parent_finalized + && let Err(error) = self.retain_terminal_child_lease(&execution).await { self.insert_execution(issue_id, execution); return Err(error); } + let parent_workspace = self + .is_parent_integration_workspace(&issue_id, !execution.issue().sub_issues.is_empty()); let mut retry_cleanup_succeeded = retain_failed; - if cleanup_terminal + if !parent_workspace + && cleanup_terminal + && parent_finalized && remote_stopped && !retain_failed && let Some(workspace) = execution.workspace().cloned() @@ -4533,10 +5300,13 @@ where async fn resolve_finished_execution( &mut self, mut execution: IssueExecution, - outcome: WorkerOutcomeRecord, + mut outcome: WorkerOutcomeRecord, observed_at: TimestampMs, ) -> Result { + let issue_id = execution.issue().id.clone(); if let Some(reason) = non_active_release_reason(execution.issue().state.category.clone()) { + self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) + .await?; return self .release_finished_execution(execution, observed_at, reason, Some(outcome)) .await; @@ -4550,6 +5320,8 @@ where outcome.outcome, WorkerOutcomeKind::Detached | WorkerOutcomeKind::CancelFailed ) { + self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) + .await?; return self .release_finished_execution( execution, @@ -4560,6 +5332,8 @@ where .await; } if acknowledged_operator_cancel_terminal(&execution, &outcome) { + self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) + .await?; return self .release_finished_execution( execution, @@ -4570,7 +5344,6 @@ where .await; } - let issue_id = execution.issue().id.clone(); if let Some(state) = self .refresh_finished_issue_state(&issue_id, observed_at) .await @@ -4581,12 +5354,27 @@ where if let Some(reason) = non_active_release_reason(execution.issue().state.category.clone()) { + self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) + .await?; return self .release_finished_execution(execution, observed_at, reason, Some(outcome)) .await; } } + self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) + .await?; + if self.parent_waiting_for_tracker_confirmation(&issue_id) { + return self + .release_finished_execution( + execution, + observed_at, + ReleaseReason::Completed, + Some(outcome), + ) + .await; + } + let retry_count = execution .current_run() .map(|run| run.normal_retry_count) @@ -4648,7 +5436,15 @@ where reason, ReleaseReason::TrackerTerminal | ReleaseReason::RetryExhausted ); - if cleanup_terminal { + let parent_finalized = self.parent_finalized_success( + &execution.issue().id, + !execution.issue().sub_issues.is_empty(), + ); + let parent_workspace = self.is_parent_integration_workspace( + &execution.issue().id, + !execution.issue().sub_issues.is_empty(), + ); + if cleanup_terminal && parent_finalized { self.retain_terminal_child_lease(&execution).await?; } let successful_terminal_outcome = reason == ReleaseReason::Completed @@ -4656,21 +5452,10 @@ where && outcome .as_ref() .is_some_and(|outcome| outcome.outcome == WorkerOutcomeKind::Succeeded)); - if successful_terminal_outcome { + if successful_terminal_outcome && parent_finalized { self.record_terminal_orchestrator_success(&execution.issue().id) .await?; } - if matches!( - reason, - ReleaseReason::TrackerTerminal | ReleaseReason::Completed - ) { - self.release_parent_leases_after_finalization( - &execution.issue().id, - execution.issue().parent_id.as_ref(), - observed_at, - ) - .await?; - } if reason == ReleaseReason::RetryExhausted { let normal_retry_count = execution .current_run() @@ -4687,6 +5472,8 @@ where execution.set_retry_count_override(normal_retry_count); let retain_failed = self.workspace.retain_failed_workspaces() || !persisted; if cleanup_terminal + && parent_finalized + && !parent_workspace && !retain_failed && let Some(workspace) = execution.workspace().cloned() && !self.workspace_has_active_lease(&workspace).await? @@ -4709,6 +5496,8 @@ where let retain_failed = reason == ReleaseReason::RetryExhausted && self.workspace.retain_failed_workspaces(); if cleanup_terminal + && parent_finalized + && !parent_workspace && !retain_failed && let Some(workspace) = execution.workspace().cloned() && !self.workspace_has_active_lease(&workspace).await? @@ -4750,31 +5539,48 @@ where Ok(()) } - async fn release_parent_leases_after_finalization( - &mut self, - parent_id: &IssueId, - higher_parent_id: Option<&IssueId>, - released_at: TimestampMs, - ) -> Result<(), SchedulerError> { - let mut next_state = self.hierarchy_state.clone(); - let has_higher_parent = - higher_parent_id.is_some() || next_state.has_ancestor_edge(parent_id); - // Tracker parent links can change before the next hierarchy refresh; - // defer descendant release until that reachability-aware pass. - let released = if has_higher_parent { - next_state.release_parent_leases_preserving_ancestor(parent_id, released_at.as_u64()) - } else { - next_state.release_parent_leases(parent_id, released_at.as_u64()) - }; - if !released { - return Ok(()); - } - let previous_state = std::mem::replace(&mut self.hierarchy_state, next_state); - if let Err(error) = self.persist_orchestrator_state().await { - self.hierarchy_state = previous_state; - return Err(error); + fn parent_finalized_success(&self, issue_id: &IssueId, issue_has_children: bool) -> bool { + match self.hierarchy_state.parent_integrations.get(issue_id) { + Some(controller) => { + controller.state == super::ParentIntegrationState::Completed + && self + .hierarchy_state + .hierarchy + .get(issue_id) + .is_some_and(|snapshot| { + snapshot.accepts_event(controller.hierarchy_generation) + }) + } + None => !issue_has_children, } - Ok(()) + } + + // Parent capture retries after daemon restart from the durable runtime + // envelope, so cleanup remains deferred to the capture-aware lifecycle. + fn is_parent_integration_workspace( + &self, + issue_id: &IssueId, + issue_has_children: bool, + ) -> bool { + issue_has_children + || self + .hierarchy_state + .parent_integrations + .contains_key(issue_id) + } + + fn parent_waiting_for_tracker_confirmation(&self, issue_id: &IssueId) -> bool { + self.hierarchy_state + .parent_integrations + .get(issue_id) + .is_some_and(|controller| { + !controller.state.terminal() + && controller.current_attempt_id().is_none() + && controller + .attempts + .last() + .is_some_and(|attempt| attempt.status == ParentAttemptStatus::Passed) + }) } async fn persist_retry_exhaustion( @@ -5011,6 +5817,9 @@ where reason: ReleaseReason::TrackerTerminal | ReleaseReason::Completed, .. } + ) && self.parent_finalized_success( + &child.child_id, + !execution.issue().sub_issues.is_empty(), ) }, ); @@ -5192,6 +6001,7 @@ where return Ok(false); } next_snapshot.mark_dispatch_intent(); + let input_version = parent_input_version(next_snapshot); let mut required_leases = evidence.integration_leases(&normalized.id, observed_at.as_u64()); required_leases.extend(evidence.children.iter().flat_map(|child| { child @@ -5213,11 +6023,27 @@ where .map_err(|error| SchedulerError::Workspace { detail: error.to_string(), })?; + let controller = next_state + .parent_integrations + .entry(normalized.id.clone()) + .or_insert(ParentIntegrationController::new( + normalized.id.clone(), + snapshot.generation, + )?); + let controller_replaced = controller.hierarchy_generation != snapshot.generation; + if controller_replaced { + *controller = + ParentIntegrationController::new(normalized.id.clone(), snapshot.generation)?; + } + controller.admit(&input_version, observed_at)?; let previous_state = std::mem::replace(&mut self.hierarchy_state, next_state); if let Err(error) = self.persist_orchestrator_state().await { self.hierarchy_state = previous_state; return Err(error); } + if controller_replaced { + self.reopen_completed_parent_for_new_controller(normalized, observed_at)?; + } Ok(true) } @@ -5831,6 +6657,12 @@ impl TrackerSnapshot { self.terminal_state_by_id.contains_key(issue_id) } + fn terminal_issue(&self, issue_id: &IssueId) -> Option<&TrackerIssue> { + self.terminal + .iter() + .find(|issue| issue.id == issue_id.as_str()) + } + fn terminal_state_name(&self, issue_id: &str) -> Option<&str> { self.terminal_state_by_id.get(issue_id).map(String::as_str) } @@ -6462,6 +7294,251 @@ fn conversation_id_suffix(value: &str) -> &str { value.get(value.len().saturating_sub(8)..).unwrap_or(value) } +fn parent_input_version(snapshot: &HierarchySnapshot) -> String { + let merges = snapshot + .dispatch_required_merge_commits + .iter() + .map(|commit| { + format!( + "{}@{}", + commit + .repository_id + .as_ref() + .map_or("", |repository| repository.as_str()), + commit.commit + ) + }) + .collect::>() + .join(","); + format!("hierarchy:{};merges:{merges}", snapshot.generation) +} + +fn parent_targets_input_version( + hierarchy_generation: u64, + targets: &[ParentRepositoryTarget], +) -> String { + let targets = targets + .iter() + .map(|target| { + format!( + "{}:{}:{}@{}", + target.repository_id, + target.checkout_handle, + target.relative_path.display(), + target.target_commit + ) + }) + .collect::>() + .join(","); + format!("hierarchy:{hierarchy_generation};targets:{targets}") +} + +fn parent_controller_input_version(controller: &ParentIntegrationController) -> String { + parent_targets_input_version( + controller.hierarchy_generation, + &controller.targets.values().cloned().collect::>(), + ) +} + +fn observe_parent_command_event( + controller: &mut ParentIntegrationController, + attempt_id: &str, + parent_workspace_path: Option<&Path>, + observed_at: TimestampMs, + event_id: Option<&str>, + event_kind: Option<&str>, + payload: Option<&serde_json::Value>, +) -> Result<(), ParentIntegrationError> { + let Some(kind) = event_kind else { + return Ok(()); + }; + let Some(payload) = payload else { + return Ok(()); + }; + if kind == "codex.item/started" || kind == "codex.item/completed" { + let params = payload.get("params").unwrap_or(payload); + let item = params.get("item").unwrap_or(params); + let item_type = item + .get("type") + .or_else(|| params.get("type")) + .and_then(serde_json::Value::as_str); + if item_type != Some("commandExecution") { + return Ok(()); + } + let command_id = item + .get("id") + .and_then(serde_json::Value::as_str) + .or(event_id); + let command = item.get("command").and_then(serde_json::Value::as_str); + if kind == "codex.item/started" { + if let (Some(command_id), Some(command)) = (command_id, command) { + let cwd = item + .get("cwd") + .or_else(|| item.get("workingDirectory")) + .or_else(|| params.get("cwd")) + .or_else(|| params.get("workingDirectory")) + .and_then(serde_json::Value::as_str); + let root = observed_parent_command_root(controller, parent_workspace_path, cwd)?; + controller.observe_command_started( + attempt_id, + command_id, + command, + root, + observed_at, + )?; + } + return Ok(()); + } + if let (Some(command_id), Some(exit_code)) = ( + command_id, + item.get("exitCode") + .and_then(serde_json::Value::as_i64) + .and_then(|value| i32::try_from(value).ok()), + ) { + let output = item + .get("aggregatedOutput") + .and_then(serde_json::Value::as_str) + .map(redact_runtime_diagnostic); + controller.observe_command_finished( + attempt_id, + command_id, + exit_code, + output.as_deref(), + observed_at, + )?; + } + return Ok(()); + } + if kind.ends_with("ActionEvent") { + let command = nested_runtime_string(payload, &["command"]); + let command_id = payload + .get("action_id") + .and_then(serde_json::Value::as_str) + .or(event_id); + if let (Some(command_id), Some(command)) = (command_id, command) { + let cwd = nested_runtime_string(payload, &["cwd", "working_dir"]); + let root = observed_parent_command_root(controller, parent_workspace_path, cwd)?; + controller.observe_command_started( + attempt_id, + command_id, + command, + root, + observed_at, + )?; + } + } else if kind.ends_with("ObservationEvent") + && let Some(exit_code) = payload + .get("exit_code") + .and_then(serde_json::Value::as_i64) + .and_then(|value| i32::try_from(value).ok()) + && let Some(command_id) = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .and_then(|attempt| { + attempt + .commands + .iter() + .rev() + .find(|command| command.finished_at.is_none()) + .map(|command| command.command_id.clone()) + }) + { + let output = payload + .get("preview") + .and_then(serde_json::Value::as_str) + .map(redact_runtime_diagnostic); + controller.observe_command_finished( + attempt_id, + &command_id, + exit_code, + output.as_deref(), + observed_at, + )?; + } + Ok(()) +} + +fn nested_runtime_string<'a>(payload: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> { + [Some(payload), payload.get("arguments"), payload.get("args")] + .into_iter() + .flatten() + .find_map(|object| { + keys.iter() + .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str)) + }) +} + +fn observed_parent_command_root( + controller: &ParentIntegrationController, + parent_workspace_path: Option<&Path>, + cwd: Option<&str>, +) -> Result { + let Some(cwd) = cwd.filter(|cwd| !cwd.trim().is_empty()) else { + return Ok(ParentAttemptRoot::ParentRoot); + }; + let cwd = PathBuf::from(cwd); + let relative = if cwd.is_absolute() { + let parent = parent_workspace_path.ok_or_else(|| { + ParentIntegrationError::InvalidVerificationEvidence( + "runtime command reported an absolute cwd without a parent workspace binding" + .to_owned(), + ) + })?; + cwd.strip_prefix(parent).map_err(|_| { + ParentIntegrationError::InvalidVerificationEvidence( + "runtime command cwd is outside the parent integration workspace".to_owned(), + ) + })? + } else { + cwd.as_path() + }; + if relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(ParentIntegrationError::InvalidVerificationEvidence( + "runtime command cwd is not a contained parent workspace path".to_owned(), + )); + } + if relative.as_os_str().is_empty() || relative == Path::new(".") { + return Ok(ParentAttemptRoot::ParentRoot); + } + controller + .targets + .values() + .find(|target| target.relative_path == relative) + .map(|target| ParentAttemptRoot::CheckoutHandle(target.checkout_handle.clone())) + .ok_or_else(|| { + ParentIntegrationError::InvalidVerificationEvidence( + "runtime command cwd does not match a verified parent checkout".to_owned(), + ) + }) +} + +fn enforce_parent_outcome_trust( + outcome: &mut WorkerOutcomeRecord, + deadline_reached: bool, + verification_passed: bool, +) { + if deadline_reached && outcome.outcome == WorkerOutcomeKind::Succeeded { + outcome.outcome = WorkerOutcomeKind::TimedOut; + outcome.summary = Some("parent attempt exceeded its absolute deadline".to_owned()); + outcome.error = Some("parent worker outcome arrived after the attempt deadline".to_owned()); + } else if outcome.outcome == WorkerOutcomeKind::Succeeded && !verification_passed { + outcome.outcome = WorkerOutcomeKind::Failed; + outcome.summary = Some("parent final verification did not pass".to_owned()); + outcome.error = Some(match outcome.error.take() { + Some(existing) => format!( + "{existing}; parent success lacked matching orchestrator-owned command evidence" + ), + None => "parent success lacked matching orchestrator-owned command evidence".to_owned(), + }); + } +} + #[cfg(test)] mod tests { use super::*; @@ -6472,6 +7549,39 @@ mod tests { assert_eq!(conversation_id_suffix("short"), "short"); } + #[test] + fn parent_outcome_trust_enforces_deadline_and_runtime_verification() { + let outcome = || WorkerOutcomeRecord { + worker_id: WorkerId::new("parent-worker").expect("worker id"), + attempt: None, + outcome: WorkerOutcomeKind::Succeeded, + started_at: TimestampMs::new(3), + finished_at: TimestampMs::new(103), + turn_count: 1, + summary: Some("claimed success".to_owned()), + error: None, + parent_verification: None, + }; + let mut late = outcome(); + enforce_parent_outcome_trust(&mut late, true, true); + assert_eq!(late.outcome, WorkerOutcomeKind::TimedOut); + assert!( + late.error + .as_deref() + .is_some_and(|error| error.contains("deadline")) + ); + + let mut unverified = outcome(); + enforce_parent_outcome_trust(&mut unverified, false, false); + assert_eq!(unverified.outcome, WorkerOutcomeKind::Failed); + assert!( + unverified + .error + .as_deref() + .is_some_and(|error| error.contains("orchestrator-owned command evidence")) + ); + } + #[test] fn canceled_tracker_children_are_not_reachable_for_lease_release() { let mut issue = tracker_issue_from_normalized(&issue_with_project(None, None)); @@ -6536,6 +7646,204 @@ mod tests { assert_eq!(recovered_worker_ordinal(&custom), None); } + #[test] + fn openhands_command_events_supply_exit_and_teardown_receipts() { + let mut controller = ParentIntegrationController::new( + IssueId::new("parent-command-events").expect("parent id"), + 1, + ) + .expect("controller"); + controller + .admit("hierarchy:1", TimestampMs::new(1)) + .expect("admit"); + controller + .record_workspace_prepared( + [ParentRepositoryTarget { + repository_id: crate::opensymphony_domain::CanonicalRepositoryId::new( + "github:repository:one", + ) + .expect("repository id"), + checkout_handle: "checkout-one".to_owned(), + relative_path: PathBuf::from("repositories/one"), + target_commit: "abc123".to_owned(), + }], + "targets:1", + TimestampMs::new(2), + ) + .expect("workspace"); + let attempt_id = controller + .start_attempt( + "parent harness", + "attempt:1", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + + observe_parent_command_event( + &mut controller, + &attempt_id, + Some(Path::new("/parent")), + TimestampMs::new(10), + Some("action-1"), + Some("ActionEvent"), + Some(&serde_json::json!({ + "action_id": "action-1", + "tool_name": "terminal", + "arguments": { + "command": "cargo test", + "cwd": "/parent/repositories/one" + } + })), + ) + .expect("command start"); + observe_parent_command_event( + &mut controller, + &attempt_id, + None, + TimestampMs::new(20), + Some("observation-1"), + Some("ObservationEvent"), + Some(&serde_json::json!({ + "observation_id": "observation-1", + "tool_name": "terminal", + "exit_code": 0, + "preview": "passed token=secret" + })), + ) + .expect("command completion"); + + let attempt = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .expect("attempt receipt"); + assert_eq!( + attempt + .commands + .last() + .map(|command| command.command.as_str()), + Some("cargo test") + ); + assert_eq!(attempt.exit_code, Some(0)); + assert_eq!( + attempt.commands.last().map(|command| &command.root), + Some(&ParentAttemptRoot::CheckoutHandle( + "checkout-one".to_owned() + )) + ); + assert_eq!( + attempt.cleanup.as_ref().map(|cleanup| cleanup.status), + Some(ParentCleanupStatus::Succeeded) + ); + assert!(attempt.bounded_log.contains("token=[redacted]")); + assert!(attempt.resources.iter().any(|resource| { + resource.kind == "foreground_process" + && resource.identifier == "action-1" + && resource.status + == crate::opensymphony_orchestrator::ParentResourceStatus::Released + })); + } + + #[test] + fn codex_command_events_cannot_backdate_completion_past_orchestrator_deadline() { + let mut controller = ParentIntegrationController::new( + IssueId::new("parent-command-deadline").expect("parent id"), + 1, + ) + .expect("controller"); + controller + .admit("hierarchy:1", TimestampMs::new(1)) + .expect("admit"); + controller + .record_workspace_prepared( + [ParentRepositoryTarget { + repository_id: crate::opensymphony_domain::CanonicalRepositoryId::new( + "github:repository:one", + ) + .expect("repository id"), + checkout_handle: "checkout-one".to_owned(), + relative_path: PathBuf::from("repositories/one"), + target_commit: "abc123".to_owned(), + }], + "targets:1", + TimestampMs::new(2), + ) + .expect("workspace"); + let attempt_id = controller + .start_attempt( + "parent harness", + "attempt:1", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + + observe_parent_command_event( + &mut controller, + &attempt_id, + None, + TimestampMs::new(10), + Some("command-1"), + Some("codex.item/started"), + Some(&serde_json::json!({ + "params": { + "startedAtMs": 1, + "item": { + "id": "command-1", + "type": "commandExecution", + "command": "cargo test" + } + } + })), + ) + .expect("command start"); + let error = observe_parent_command_event( + &mut controller, + &attempt_id, + None, + TimestampMs::new(103), + Some("command-1"), + Some("codex.item/completed"), + Some(&serde_json::json!({ + "params": { + "completedAtMs": 20, + "item": { + "id": "command-1", + "type": "commandExecution", + "exitCode": 0, + "aggregatedOutput": "passed" + } + } + })), + ) + .expect_err("scheduler observation time enforces the absolute deadline"); + assert!(error.to_string().contains("after the attempt deadline")); + + let attempt = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .expect("attempt receipt"); + assert_eq!(attempt.exit_code, None); + assert_eq!( + attempt.cleanup.as_ref().map(|cleanup| cleanup.status), + Some(ParentCleanupStatus::Pending) + ); + assert!(attempt.resources.iter().any(|resource| { + resource.kind == "foreground_process" + && resource.identifier == "command-1" + && resource.status + == crate::opensymphony_orchestrator::ParentResourceStatus::Allocated + })); + } + #[test] fn project_identity_drift_requires_reconciliation_supersession() { let previous = issue_with_project(Some("project-a"), Some("project-a")); diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 867441253..163d3a830 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -6,9 +6,9 @@ use std::{ use crate::opensymphony_domain::{ CanonicalRepositoryId, DurationMs, HarnessInterruptCommand, HarnessInterruptReason, - HarnessInterruptStatus, RepositoryBinding, RepositoryBindingOutcome, RepositoryIdentity, - RepositoryInventoryEntry, RepositoryRouting, RepositoryRoutingMode, SafeRemoteFingerprint, - TrackerErrorCategory, TrackerIssueRef, + HarnessInterruptStatus, ParentVerificationEvidence, RepositoryBinding, + RepositoryBindingOutcome, RepositoryIdentity, RepositoryInventoryEntry, RepositoryRouting, + RepositoryRoutingMode, SafeRemoteFingerprint, TrackerErrorCategory, TrackerIssueRef, }; use crate::opensymphony_orchestrator::{ ChildEligibilityEvidence, ConversationId, ConversationMetadata, HierarchySnapshot, IssueId, @@ -317,6 +317,26 @@ fn conversation(worker_id: &WorkerId) -> ConversationMetadata { } } +fn parent_verification_evidence( + run_id: &str, + hierarchy_generation: u64, + repository_id: CanonicalRepositoryId, + target_commit: &str, +) -> ParentVerificationEvidence { + ParentVerificationEvidence { + schema_version: 1, + run_id: run_id.to_owned(), + attempt: 1, + hierarchy_generation, + repository_commits: BTreeMap::from([(repository_id, target_commit.to_owned())]), + command: "cargo test-system-duckdb --test integration".to_owned(), + command_hash: crate::opensymphony_orchestrator::parent_command_identity( + "cargo test-system-duckdb --test integration", + ), + root: "parent_root".to_owned(), + } +} + #[derive(Debug, Clone)] struct FakeError { message: String, @@ -493,6 +513,7 @@ struct FakeWorkspace { persisted_durable_states: Vec, persist_durable_state_results: VecDeque>, recovered_run_started_at: BTreeMap, + parent_targets: Vec, } impl WorkspaceBackend for FakeWorkspace { @@ -524,6 +545,14 @@ impl WorkspaceBackend for FakeWorkspace { Ok(self.recoveries.clone()) } + async fn parent_workspace_targets( + &mut self, + _issue: &NormalizedIssue, + _workspace: &WorkspaceRecord, + ) -> Result, Self::Error> { + Ok(self.parent_targets.clone()) + } + async fn recovered_run_started_at( &mut self, ) -> Result, Self::Error> { @@ -762,27 +791,38 @@ async fn parent_provider_cooldown_stops_later_lookups_in_the_same_dispatch_pass( assert_eq!(scheduler.tracker().parent_evidence_requests, 1); } -#[tokio::test] -async fn eligible_parent_dispatches_once_from_durable_claim_after_restart() { - let child_id = IssueId::new("child-1").expect("child id should be valid"); - let child_identifier = IssueIdentifier::new("COE-1-child").expect("child identifier"); - let parent_id = IssueId::new("parent-1").expect("parent id should be valid"); - let mut parent = tracker_issue("parent-1", "COE-1", "In Progress", 0); +async fn launched_parent_scheduler( + suffix: &str, +) -> (Scheduler, IssueId) { + launched_parent_scheduler_with_config(suffix, scheduler_config()).await +} + +async fn launched_parent_scheduler_with_config( + suffix: &str, + config: SchedulerConfig, +) -> (Scheduler, IssueId) { + let parent_id = IssueId::new(format!("parent-{suffix}")).expect("parent id"); + let child_id = IssueId::new(format!("child-{suffix}")).expect("child id"); + let parent_identifier = format!("COE-PARENT-{suffix}"); + let child_identifier = format!("COE-CHILD-{suffix}"); + let mut parent = tracker_issue(parent_id.as_str(), &parent_identifier, "In Progress", 0); parent.sub_issues = vec![TrackerIssueRef { id: child_id.to_string(), - identifier: child_identifier.to_string(), + identifier: child_identifier, title: Some("Child".to_owned()), url: None, state: "Done".to_owned(), state_kind: TrackerIssueStateKind::Completed, }]; let snapshot = HierarchySnapshot::new(&parent); + let repository_id = + CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"); let resource = LeaseResource { issue_id: child_id.clone(), - repository_id: CanonicalRepositoryId::new("github:repo").expect("repository id"), - checkout_generation: "checkout-1".to_owned(), + repository_id: repository_id.clone(), + checkout_generation: format!("checkout-{suffix}"), }; - let mut durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { hierarchy: BTreeMap::from([(parent_id.clone(), snapshot.clone())]), leases: vec![LeaseRecord { kind: LeaseKind::LeafWorker, @@ -793,194 +833,1403 @@ async fn eligible_parent_dispatches_once_from_durable_claim_after_restart() { expires_at: None, released_at: None, }], + terminal_orchestrator_issues: [child_id.clone()].into_iter().collect(), ..Default::default() }; let evidence = ParentEligibilityEvidence { hierarchy_generation: snapshot.generation, children: vec![ChildEligibilityEvidence { - child_id: child_id.clone(), + child_id, hierarchy_generation: snapshot.generation, orchestrator_terminal: true, provider_merge_confirmed: true, merge_required: true, - merge_result_commit: Some("merge-commit".to_owned()), + merge_result_commit: Some(format!("commit-{suffix}")), merge_result_commits: Vec::new(), merge_result_commits_by_repository: vec![RequiredMergeCommit { - repository_id: Some(resource.repository_id.clone()), - commit: "merge-commit".to_owned(), + repository_id: Some(repository_id.clone()), + commit: format!("commit-{suffix}"), }], - merge_repository_id: Some(resource.repository_id.clone()), - merge_repository_ids: vec![resource.repository_id.clone()], + merge_repository_id: Some(repository_id.clone()), + merge_repository_ids: vec![repository_id.clone()], provider_evidence_at: None, provider_evidence_by_issue: Vec::new(), - resource: Some(resource.clone()), + resource: Some(resource), resources: Vec::new(), unresolved_failure: None, }], }; - // Provider terminal flags cannot substitute for scheduler-owned outcomes, - // including when a canceled child no longer requires merge evidence. - for merge_required in [true, false] { - let mut forged_evidence = evidence.clone(); - forged_evidence.children[0].merge_required = merge_required; - if !merge_required { - let child = &mut forged_evidence.children[0]; - child.merge_result_commit = None; - child.merge_result_commits_by_repository.clear(); - child.merge_repository_id = None; - child.merge_repository_ids.clear(); - child.resource = None; - } - let mut scheduler = Scheduler::new( - FakeTracker { - active: vec![parent.clone()], - parent_evidence: Some(forged_evidence), - ..Default::default() - }, - FakeWorkspace { - durable_state: Some( - serde_json::to_value(&durable_state).expect("state should encode"), - ), - ..Default::default() - }, - FakeWorker::default(), - scheduler_config(), - ); - scheduler.tick(ts(100)).await.expect("parent should defer"); - assert!( - scheduler.worker().launches.is_empty(), - "provider supplied terminal authority" - ); - } - durable_state - .terminal_orchestrator_issues - .insert(child_id.clone()); - // An old success receipt must not admit the parent while a reopened child - // is unclaimed or already prepared in the same launch batch. - for child_first in [true, false] { - let mut reopened_child = tracker_issue("child-1", "COE-1-child", "In Progress", 0); - reopened_child.parent_id = Some(parent_id.to_string()); - reopened_child.priority = Some(if child_first { 0 } else { 2 }); - let mut scheduler = Scheduler::new( - FakeTracker { - active: vec![parent.clone(), reopened_child], - parent_evidence: Some(evidence.clone()), - ..Default::default() - }, - FakeWorkspace { - durable_state: Some(serde_json::to_value(&durable_state).expect("state")), - ..Default::default() - }, - FakeWorker::default(), - scheduler_config(), - ); - scheduler - .tick(ts(100)) - .await - .expect("reopened child dispatch"); - assert_eq!( - scheduler.worker().launches.len(), - 1, - "old receipt admitted parent beside reopened child" - ); - assert_eq!(scheduler.worker().launches[0].issue.id, child_id); - let persisted: crate::opensymphony_orchestrator::DurableOrchestratorState = - serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) - .expect("durable state"); - assert!(!persisted.terminal_orchestrator_issues.contains(&child_id)); - } - let mut evidence = evidence; - evidence.children[0].orchestrator_terminal = false; - let tracker = FakeTracker { - active: vec![parent.clone()], - parent_evidence: Some(evidence.clone()), - ..Default::default() - }; - let workspace = FakeWorkspace { - durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), - ..Default::default() - }; - let worker = FakeWorker::default(); - let mut scheduler = Scheduler::new(tracker, workspace, worker, scheduler_config()); + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent], + parent_evidence: Some(evidence), + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("durable state")), + parent_targets: vec![crate::opensymphony_orchestrator::ParentRepositoryTarget { + repository_id, + checkout_handle: format!("checkout-{suffix}"), + relative_path: PathBuf::from(format!("repositories/{suffix}")), + target_commit: format!("commit-{suffix}"), + }], + ..Default::default() + }, + FakeWorker::default(), + config, + ); + scheduler.tick(ts(100)).await.expect("launch parent"); + assert_eq!(scheduler.worker().launches.len(), 1); + (scheduler, parent_id) +} +fn enqueue_successful_parent_completion( + scheduler: &mut Scheduler, + suffix: &str, + finished_at: u64, +) { + let run = scheduler.worker().launches[0].run.clone(); + let command = "cargo test-system-duckdb --test integration"; scheduler - .tick(ts(100)) + .worker_mut() + .updates + .push_back(WorkerUpdate::RuntimeEvent { + worker_id: run.worker_id.clone(), + observed_at: ts(finished_at - 20), + event_id: Some("command-final".to_owned()), + event_kind: Some("codex.item/started".to_owned()), + summary: Some("final verification started".to_owned()), + payload: Some(serde_json::json!({ + "params": {"item": { + "id": "command-final", + "type": "commandExecution", + "command": command + }} + })), + }); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::RuntimeEvent { + worker_id: run.worker_id.clone(), + observed_at: ts(finished_at - 10), + event_id: Some("command-final".to_owned()), + event_kind: Some("codex.item/completed".to_owned()), + summary: Some("final verification passed".to_owned()), + payload: Some(serde_json::json!({ + "params": {"item": { + "id": "command-final", + "type": "commandExecution", + "command": command, + "exitCode": 0, + "aggregatedOutput": "integration passed" + }} + })), + }); + let mut outcome = WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Succeeded, + ts(finished_at), + Some("parent integration verified".to_owned()), + None, + ); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let hierarchy_generation = state + .parent_integrations + .get(&run.issue_id) + .expect("parent controller") + .hierarchy_generation; + outcome.parent_verification = Some(parent_verification_evidence( + run.worker_id.as_str(), + hierarchy_generation, + CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"), + &format!("commit-{suffix}"), + )); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: run.worker_id, + outcome, + }); +} + +#[tokio::test] +async fn parent_runtime_command_event_is_persisted_before_worker_completion() { + let (mut scheduler, parent_id) = launched_parent_scheduler("EVENT-PERSIST").await; + let worker_id = scheduler.worker().launches[0].run.worker_id.clone(); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::RuntimeEvent { + worker_id, + observed_at: ts(120), + event_id: Some("command-persist".to_owned()), + event_kind: Some("codex.item/started".to_owned()), + summary: Some("verification started".to_owned()), + payload: Some(serde_json::json!({ + "params": { + "startedAtMs": 120, + "item": { + "id": "command-persist", + "type": "commandExecution", + "command": "cargo test-system-duckdb --test integration" + } + } + })), + }); + + scheduler + .tick(ts(120)) .await - .expect("eligible parent should dispatch"); + .expect("runtime event should persist independently"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let attempt = state.parent_integrations[&parent_id] + .attempts + .last() + .expect("parent attempt"); + assert_eq!(attempt.commands.len(), 1, "{attempt:?}"); + assert_eq!(attempt.commands[0].command_id, "command-persist"); + assert!(attempt.finished_at.is_none()); +} + +#[tokio::test] +async fn dry_run_parent_preview_does_not_bind_its_conversation() { + let mut config = scheduler_config(); + config.routing.dry_run = true; + let (mut scheduler, parent_id) = launched_parent_scheduler_with_config("DRY-RUN", config).await; + assert_eq!(scheduler.worker().launches.len(), 1); - assert!(scheduler.workspace().persisted_durable_states.iter().any(|state| { - serde_json::from_value::( - state.clone(), - ) - .ok() - .and_then(|state| state.hierarchy.get(&parent_id).cloned()) - .is_some_and(|snapshot| { - snapshot.dispatch_intended() - && snapshot.dispatch_required_merge_commits - == vec![RequiredMergeCommit { - repository_id: Some(resource.repository_id.clone()), - commit: "merge-commit".to_owned(), - }] - }) - })); - durable_state = serde_json::from_value( - scheduler - .workspace() - .durable_state - .clone() - .expect("dispatch should persist durable state"), - ) - .expect("durable state should decode"); - assert!(durable_state.hierarchy[&parent_id].dispatch_claimed()); + assert!(scheduler.worker().launches[0].route.dry_run); assert!( - durable_state - .leases - .iter() - .any(|lease| { lease.kind == LeaseKind::AncestorIntegration && lease.active() }) - ); - assert!( - durable_state - .leases - .iter() - .any(|lease| { lease.kind == LeaseKind::LeafWorker && lease.released_at.is_some() }) + scheduler.worker().launches[0] + .expected_parent_conversation_id + .is_none() ); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let controller = &state.parent_integrations[&parent_id]; + assert!(controller.conversation_id.is_none()); + assert!(controller.attempts.is_empty()); - let tracker = FakeTracker { - active: vec![parent], - parent_evidence: Some(evidence), - ..Default::default() - }; - let workspace = FakeWorkspace { - durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), - ..Default::default() - }; - let mut restarted = Scheduler::new( - tracker, - workspace, - FakeWorker::default(), - scheduler_config(), - ); - restarted - .tick(ts(200)) + let preview_run = scheduler.worker().launches[0].run.clone(); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: preview_run.worker_id.clone(), + outcome: WorkerOutcomeRecord::from_run( + &preview_run, + WorkerOutcomeKind::Succeeded, + ts(120), + Some("routing preview completed".to_owned()), + None, + ), + }); + scheduler + .tick(ts(120)) .await - .expect("restart should respect the durable dispatch claim"); - assert!(restarted.worker().launches.is_empty()); + .expect("dry-run completion must stay outside the parent controller"); + + let execution = scheduler + .execution(&parent_id) + .expect("preview execution remains observable"); + assert_eq!(execution.status(), SchedulerStatus::Released); + assert!(execution.retry().is_none()); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let controller = &state.parent_integrations[&parent_id]; + assert!(controller.attempts.is_empty()); + assert!(!matches!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::Failed { .. } + )); } #[tokio::test] -async fn hierarchy_reconciliation_preserves_reparented_leases_and_releases_terminal_roots() { - let child_id = IssueId::new("child").expect("child"); - let mut old_parent = tracker_issue("old-parent", "COE-OLD", "In Progress", 0); - old_parent.sub_issues = vec![TrackerIssueRef { - id: child_id.to_string(), - identifier: "COE-CHILD".to_owned(), - title: None, - url: None, - state: "Done".to_owned(), - state_kind: TrackerIssueStateKind::Completed, - }]; - for reparented in [true, false] { - let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { +async fn completed_parent_stays_materialized_across_later_capture_retries() { + let suffix = "CAPTURE-WINDOW"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let launch = scheduler.worker().launches[0].clone(); + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + + scheduler + .tick(ts(150)) + .await + .expect("terminal parent outcome should complete"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + ); + assert!( + scheduler.workspace().cleaned.is_empty(), + "the run loop must be able to load completed bindings after this tick" + ); + assert!(state.leases.iter().any(LeaseRecord::active)); + + scheduler.tracker_mut().active.clear(); + let mut terminal_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + 0, + ); + terminal_parent.sub_issues = vec![TrackerIssueRef { + id: format!("child-{suffix}"), + identifier: format!("COE-CHILD-{suffix}"), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + scheduler.tracker_mut().terminal = vec![terminal_parent.clone()]; + scheduler + .tick(ts(3_600_200)) + .await + .expect("later reconciliation should retain the capturable parent"); + assert!( + scheduler.workspace().cleaned.is_empty(), + "OSYM-893 owns durable parent cleanup after capture acknowledgement" + ); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert!( + state.leases.iter().any(LeaseRecord::active), + "OSYM-893 owns ordered lease release after capture acknowledgement" + ); + + let mut restarted = Scheduler::new( + FakeTracker { + terminal: vec![terminal_parent], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&state).expect("persisted state")), + recoveries: vec![RecoveryRecord { + issue: normalized_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + ), + workspace: workspace_record( + &format!("COE-PARENT-{suffix}"), + &format!("/tmp/recovered/COE-PARENT-{suffix}"), + ), + successful_run: true, + cancelled_run: false, + completed_run: true, + had_in_flight_run: true, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some(launch.route.harness_kind.clone()), + interrupt_reason: None, + recovered_run: Some(RecoveredRun { + worker_id: launch.run.worker_id.clone(), + conversation: conversation(&launch.run.worker_id), + normal_retry_count: 0, + repository_binding: launch.run.repository_binding.clone(), + }), + }], + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + restarted + .bootstrap(ts(3_600_300)) + .await + .expect("restart should preserve completed parent evidence"); + assert!( + restarted.workspace().cleaned.is_empty(), + "restart recovery must retain the parent root for another capture attempt" + ); + let restarted_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + restarted + .workspace() + .durable_state + .clone() + .expect("restarted state"), + ) + .expect("decode restarted state"); + assert!( + restarted_state.leases.iter().any(LeaseRecord::active), + "restart must retain the leases that protect parent evidence" + ); +} + +#[tokio::test] +async fn passed_parent_survives_tracker_refresh_failure_until_terminal_reconciliation() { + let suffix = "PASSED-REFRESH"; + let mut config = scheduler_config(); + config.max_retry_attempts = Some(0); + let (mut scheduler, parent_id) = + launched_parent_scheduler_with_config(suffix, config.clone()).await; + scheduler.tracker_mut().state_errors.push_back(FakeError { + message: "temporary tracker refresh failure".to_owned(), + category: None, + retry_after: None, + }); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + + scheduler + .tick(ts(150)) + .await + .expect("verified outcome should remain durable despite tracker refresh failure"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id].attempts[0].status, + crate::opensymphony_orchestrator::ParentAttemptStatus::Passed + ); + assert!( + state.parent_integrations[&parent_id] + .current_attempt_id() + .is_none(), + "a passed attempt must not be converted into a retrying attempt" + ); + assert!(matches!( + scheduler + .execution(&parent_id) + .expect("passed parent execution remains observable") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::Completed, + .. + } + )); + let launch_count = scheduler.worker().launches.len(); + scheduler + .tick(ts(1_200)) + .await + .expect("active refresh should keep the passed parent parked"); + assert_eq!( + scheduler.worker().launches.len(), + launch_count, + "tracker confirmation must not start another parent attempt" + ); + + let recovered_issue = scheduler + .execution(&parent_id) + .expect("passed parent execution") + .issue() + .clone(); + let recovered_workspace = scheduler + .execution(&parent_id) + .and_then(|execution| execution.workspace()) + .cloned() + .expect("passed parent workspace"); + let mut durable_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("passed controller state"), + ) + .expect("decode passed controller state"); + durable_state.leases.clear(); + let active_parent = scheduler.tracker().active[0].clone(); + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![active_parent], + ..Default::default() + }, + FakeWorkspace { + recoveries: vec![RecoveryRecord { + issue: recovered_issue, + workspace: recovered_workspace, + successful_run: true, + cancelled_run: false, + completed_run: true, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: None, + }], + durable_state: Some(serde_json::to_value(durable_state).expect("passed state")), + ..Default::default() + }, + FakeWorker::default(), + config, + ); + scheduler + .tick(ts(1_300)) + .await + .expect("restart should preserve tracker-confirmation wait"); + assert!(scheduler.worker().launches.is_empty()); + assert!(matches!( + scheduler + .execution(&parent_id) + .expect("recovered passed parent") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::Completed, + .. + } + )); + + scheduler.tracker_mut().active.clear(); + let mut terminal_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + 0, + ); + terminal_parent.sub_issues = vec![TrackerIssueRef { + id: format!("child-{suffix}"), + identifier: format!("COE-CHILD-{suffix}"), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + scheduler.tracker_mut().terminal = vec![terminal_parent]; + scheduler + .tick(ts(3_600_200)) + .await + .expect("later terminal refresh should complete the preserved attempt"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + ); +} + +#[tokio::test] +async fn stale_completed_parent_does_not_release_changed_hierarchy() { + let suffix = "STALE-COMPLETED"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler + .tick(ts(150)) + .await + .expect("terminal parent outcome should complete"); + + let mut changed_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + 0, + ); + changed_parent.sub_issues = vec![TrackerIssueRef { + id: format!("replacement-child-{suffix}"), + identifier: format!("COE-REPLACEMENT-{suffix}"), + title: Some("Replacement child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + scheduler.tracker_mut().terminal = vec![changed_parent]; + scheduler + .tick(ts(3_600_200)) + .await + .expect("changed terminal hierarchy should remain fenced"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!(state.hierarchy[&parent_id].generation, 2); + assert_eq!( + state.hierarchy[&parent_id].blocked_reason, + Some(crate::opensymphony_orchestrator::HierarchyBlockedReason::HierarchyChanged) + ); + assert!( + state.leases.iter().any(LeaseRecord::active), + "a completed controller from the prior generation must not release current leases" + ); + assert!(scheduler.workspace().cleaned.is_empty()); + assert!(!state.terminal_orchestrator_issues.contains(&parent_id)); +} + +#[tokio::test] +async fn accepted_hierarchy_change_reopens_a_completed_parent_execution() { + let suffix = "COMPLETED-REPLAN"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler + .tick(ts(150)) + .await + .expect("parent integration should complete"); + assert!(matches!( + scheduler.execution(&parent_id).expect("parent").state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::Completed, + .. + } + )); + + let replacement_id = IssueId::new("replacement-completed-replan").expect("replacement id"); + let mut changed_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "In Progress", + 0, + ); + changed_parent.sub_issues = vec![TrackerIssueRef { + id: replacement_id.to_string(), + identifier: "COE-REPLACEMENT-COMPLETED-REPLAN".to_owned(), + title: Some("Replacement child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + scheduler.tracker_mut().active = vec![changed_parent]; + scheduler + .tick(ts(3_600_200)) + .await + .expect("changed frozen hierarchy should be blocked"); + let blocked_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + assert_eq!( + blocked_state.hierarchy[&parent_id].blocked_reason, + Some(crate::opensymphony_orchestrator::HierarchyBlockedReason::HierarchyChanged) + ); + scheduler + .replan_parent(&parent_id, ts(3_600_210)) + .await + .expect("replan") + .then_some(()) + .expect("changed hierarchy should be accepted"); + let replanned_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + assert!( + replanned_state.hierarchy[&parent_id] + .blocked_reason + .is_none() + ); + assert_ne!( + replanned_state.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + ); + assert_eq!( + replanned_state.parent_integrations[&parent_id].hierarchy_generation, + replanned_state.hierarchy[&parent_id].generation + ); + assert_eq!( + scheduler.execution(&parent_id).expect("parent").status(), + SchedulerStatus::Unclaimed + ); +} + +#[tokio::test] +async fn parent_release_restores_execution_and_controller_when_persistence_fails() { + let suffix = "RELEASE-ROLLBACK"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let before: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 200, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + scheduler + .workspace_mut() + .persist_durable_state_results + .push_back(Err(FakeError { + message: "injected release persistence failure".to_owned(), + category: None, + retry_after: None, + })); + + scheduler + .tick(ts(60_200)) + .await + .expect_err("release must surface the durable controller write failure"); + + assert_eq!( + scheduler + .execution(&parent_id) + .expect("execution must be restored") + .status(), + SchedulerStatus::Running + ); + let after: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + after.parent_integrations[&parent_id], + before.parent_integrations[&parent_id] + ); + assert!(scheduler.workspace().cleaned.is_empty()); +} + +#[tokio::test] +async fn legacy_in_flight_parent_migrates_controller_before_reattachment() { + let (scheduler, parent_id) = launched_parent_scheduler("LEGACY-MIGRATION").await; + let launch = scheduler.worker().launches[0].clone(); + let conversation = scheduler + .execution(&parent_id) + .and_then(|execution| execution.conversation()) + .cloned() + .expect("parent conversation"); + let active = scheduler.tracker().active.clone(); + let parent_evidence = scheduler.tracker().parent_evidence.clone(); + let parent_targets = scheduler.workspace().parent_targets.clone(); + let mut durable_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + durable_state.parent_integrations.remove(&parent_id); + + let recovery = RecoveryRecord { + issue: launch.issue.clone(), + workspace: launch.workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: true, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some(launch.route.harness_kind.clone()), + interrupt_reason: None, + recovered_run: Some(RecoveredRun { + worker_id: launch.run.worker_id.clone(), + conversation: conversation.clone(), + normal_retry_count: 0, + repository_binding: launch.run.repository_binding.clone(), + }), + }; + let mut restarted = Scheduler::new( + FakeTracker { + active, + parent_evidence, + ..Default::default() + }, + FakeWorkspace { + recoveries: vec![recovery], + durable_state: Some(serde_json::to_value(durable_state).expect("legacy state")), + recovered_run_started_at: BTreeMap::from([(parent_id.clone(), ts(100))]), + parent_targets, + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + + restarted + .tick(ts(200)) + .await + .expect("legacy parent should migrate before reattachment"); + assert_eq!(restarted.worker().launches.len(), 1); + assert_eq!( + restarted.worker().launches[0] + .expected_parent_conversation_id + .as_deref(), + Some(conversation.conversation_id.as_str()) + ); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(restarted.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let controller = &state.parent_integrations[&parent_id]; + assert_eq!( + controller.conversation_id.as_deref(), + Some(conversation.conversation_id.as_str()) + ); + assert_eq!(controller.attempts.len(), 1); +} + +#[tokio::test] +async fn recovered_parent_is_stopped_and_reinserted_when_attachment_persistence_fails() { + let (scheduler, parent_id) = launched_parent_scheduler("RECOVERY-PERSIST-FAILURE").await; + let launch = scheduler.worker().launches[0].clone(); + let conversation = scheduler + .execution(&parent_id) + .and_then(|execution| execution.conversation()) + .cloned() + .expect("parent conversation"); + let recovery = RecoveryRecord { + issue: launch.issue.clone(), + workspace: launch.workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: true, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some(launch.route.harness_kind.clone()), + interrupt_reason: None, + recovered_run: Some(RecoveredRun { + worker_id: launch.run.worker_id.clone(), + conversation, + normal_retry_count: 0, + repository_binding: launch.run.repository_binding.clone(), + }), + }; + let mut restarted = Scheduler::new( + FakeTracker { + active: scheduler.tracker().active.clone(), + parent_evidence: scheduler.tracker().parent_evidence.clone(), + ..Default::default() + }, + FakeWorkspace { + recoveries: vec![recovery], + durable_state: scheduler.workspace().durable_state.clone(), + persist_durable_state_results: VecDeque::from([ + Ok(()), + Ok(()), + Ok(()), + Err(FakeError { + message: "recovered attachment persistence failed".to_owned(), + category: None, + retry_after: None, + }), + ]), + recovered_run_started_at: BTreeMap::from([(parent_id.clone(), ts(100))]), + parent_targets: scheduler.workspace().parent_targets.clone(), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + + let error = restarted + .tick(ts(200)) + .await + .expect_err("failed recovered attachment persistence must surface"); + + assert_eq!(restarted.worker().launches.len(), 1, "{error:?}"); + assert_eq!(restarted.worker().interrupts.len(), 1, "{error:?}"); + assert_eq!(restarted.worker().aborted.len(), 1); + let execution = restarted + .execution(&parent_id) + .expect("the recovered execution must remain registered"); + assert!(matches!( + execution.state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::TrackerInactive, + .. + } + )); + assert_eq!( + execution + .last_worker_outcome() + .and_then(|outcome| outcome.error.as_deref()), + Some("workspace backend failed: recovered attachment persistence failed") + ); +} + +#[tokio::test] +async fn eligible_parent_dispatches_once_from_durable_claim_after_restart() { + let child_id = IssueId::new("child-1").expect("child id should be valid"); + let child_identifier = IssueIdentifier::new("COE-1-child").expect("child identifier"); + let parent_id = IssueId::new("parent-1").expect("parent id should be valid"); + let mut parent = tracker_issue("parent-1", "COE-1", "In Progress", 0); + parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: child_identifier.to_string(), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let snapshot = HierarchySnapshot::new(&parent); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: CanonicalRepositoryId::new("github:repo").expect("repository id"), + checkout_generation: "checkout-1".to_owned(), + }; + let mut durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot.clone())]), + leases: vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: snapshot.generation, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + ..Default::default() + }; + let evidence = ParentEligibilityEvidence { + hierarchy_generation: snapshot.generation, + children: vec![ChildEligibilityEvidence { + child_id: child_id.clone(), + hierarchy_generation: snapshot.generation, + orchestrator_terminal: true, + provider_merge_confirmed: true, + merge_required: true, + merge_result_commit: Some("merge-commit".to_owned()), + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: vec![RequiredMergeCommit { + repository_id: Some(resource.repository_id.clone()), + commit: "merge-commit".to_owned(), + }], + merge_repository_id: Some(resource.repository_id.clone()), + merge_repository_ids: vec![resource.repository_id.clone()], + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: Some(resource.clone()), + resources: Vec::new(), + unresolved_failure: None, + }], + }; + // Provider terminal flags cannot substitute for scheduler-owned outcomes, + // including when a canceled child no longer requires merge evidence. + for merge_required in [true, false] { + let mut forged_evidence = evidence.clone(); + forged_evidence.children[0].merge_required = merge_required; + if !merge_required { + let child = &mut forged_evidence.children[0]; + child.merge_result_commit = None; + child.merge_result_commits_by_repository.clear(); + child.merge_repository_id = None; + child.merge_repository_ids.clear(); + child.resource = None; + } + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent.clone()], + parent_evidence: Some(forged_evidence), + ..Default::default() + }, + FakeWorkspace { + durable_state: Some( + serde_json::to_value(&durable_state).expect("state should encode"), + ), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + scheduler.tick(ts(100)).await.expect("parent should defer"); + assert!( + scheduler.worker().launches.is_empty(), + "provider supplied terminal authority" + ); + } + durable_state + .terminal_orchestrator_issues + .insert(child_id.clone()); + // An old success receipt must not admit the parent while a reopened child + // is unclaimed or already prepared in the same launch batch. + for child_first in [true, false] { + let mut reopened_child = tracker_issue("child-1", "COE-1-child", "In Progress", 0); + reopened_child.parent_id = Some(parent_id.to_string()); + reopened_child.priority = Some(if child_first { 0 } else { 2 }); + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent.clone(), reopened_child], + parent_evidence: Some(evidence.clone()), + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state")), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + scheduler + .tick(ts(100)) + .await + .expect("reopened child dispatch"); + assert_eq!( + scheduler.worker().launches.len(), + 1, + "old receipt admitted parent beside reopened child" + ); + assert_eq!(scheduler.worker().launches[0].issue.id, child_id); + let persisted: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert!(!persisted.terminal_orchestrator_issues.contains(&child_id)); + } + let mut evidence = evidence; + evidence.children[0].orchestrator_terminal = false; + let tracker = FakeTracker { + active: vec![parent.clone()], + parent_evidence: Some(evidence.clone()), + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), + parent_targets: vec![crate::opensymphony_orchestrator::ParentRepositoryTarget { + repository_id: resource.repository_id.clone(), + checkout_handle: "checkout-repo".to_owned(), + relative_path: PathBuf::from("repositories/repo"), + target_commit: "merge-commit".to_owned(), + }], + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut scheduler = Scheduler::new(tracker, workspace, worker, scheduler_config()); + + scheduler + .tick(ts(100)) + .await + .expect("eligible parent should dispatch"); + assert_eq!(scheduler.worker().launches.len(), 1); + let parent_run = scheduler.worker().launches[0].run.clone(); + let persisted_after_launch: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("parent attempt state"), + ) + .expect("parent attempt state should decode"); + assert_eq!( + persisted_after_launch.parent_integrations[&parent_id].current_attempt_id(), + Some("parent-attempt-1") + ); + assert!(scheduler.workspace().persisted_durable_states.iter().any(|state| { + serde_json::from_value::( + state.clone(), + ) + .ok() + .and_then(|state| { + state.parent_integrations[&parent_id] + .attempts + .first() + .cloned() + }) + .is_some_and(|attempt| attempt.conversation_id.is_none()) + }), "attempt intent must be durable before the worker launch receipt attaches a conversation"); + assert!(scheduler.workspace().persisted_durable_states.iter().any(|state| { + serde_json::from_value::( + state.clone(), + ) + .ok() + .and_then(|state| state.hierarchy.get(&parent_id).cloned()) + .is_some_and(|snapshot| { + snapshot.dispatch_intended() + && snapshot.dispatch_required_merge_commits + == vec![RequiredMergeCommit { + repository_id: Some(resource.repository_id.clone()), + commit: "merge-commit".to_owned(), + }] + }) + })); + durable_state = serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("dispatch should persist durable state"), + ) + .expect("durable state should decode"); + assert!(durable_state.hierarchy[&parent_id].dispatch_claimed()); + assert!( + durable_state + .leases + .iter() + .any(|lease| { lease.kind == LeaseKind::AncestorIntegration && lease.active() }) + ); + assert!( + durable_state + .leases + .iter() + .any(|lease| { lease.kind == LeaseKind::LeafWorker && lease.released_at.is_some() }) + ); + + let mut failed_outcome = WorkerOutcomeRecord::from_run( + &parent_run, + WorkerOutcomeKind::Succeeded, + ts(150), + Some("worker claimed success despite failed verification".to_owned()), + None, + ); + failed_outcome.parent_verification = Some(parent_verification_evidence( + parent_run.worker_id.as_str(), + snapshot.generation, + resource.repository_id.clone(), + "merge-commit", + )); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::RuntimeEvent { + worker_id: parent_run.worker_id.clone(), + observed_at: ts(120), + event_id: Some("command-final".to_owned()), + event_kind: Some("codex.item/started".to_owned()), + summary: Some("final verification started".to_owned()), + payload: Some(serde_json::json!({ + "params": { + "startedAtMs": 120, + "item": { + "id": "command-final", + "type": "commandExecution", + "command": "cargo test-system-duckdb --test integration" + } + } + })), + }); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::RuntimeEvent { + worker_id: parent_run.worker_id.clone(), + observed_at: ts(140), + event_id: Some("command-final".to_owned()), + event_kind: Some("codex.item/completed".to_owned()), + summary: Some("final verification failed".to_owned()), + payload: Some(serde_json::json!({ + "params": { + "completedAtMs": 140, + "item": { + "id": "command-final", + "type": "commandExecution", + "command": "cargo test-system-duckdb --test integration", + "exitCode": 1, + "aggregatedOutput": "integration test failed token=secret" + } + } + })), + }); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: parent_run.worker_id.clone(), + outcome: failed_outcome, + }); + scheduler + .workspace_mut() + .persist_durable_state_results + .push_back(Err(FakeError { + message: "injected parent outcome persistence failure".to_owned(), + category: None, + retry_after: None, + })); + scheduler + .tick(ts(150)) + .await + .expect_err("parent outcome persistence failure must remain retryable"); + let durable_after_failed_write: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("pre-outcome controller remains durable"), + ) + .expect("durable controller should decode"); + assert_eq!( + durable_after_failed_write.parent_integrations[&parent_id].current_attempt_id(), + Some("parent-attempt-1"), + "failed persistence must retain the durable running attempt" + ); + scheduler + .tick(ts(151)) + .await + .expect("pending parent outcome should persist on retry"); + durable_state = serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("parent outcome state"), + ) + .expect("parent outcome state should decode"); + let controller = &durable_state.parent_integrations[&parent_id]; + assert_eq!( + controller.attempts[0].status, + crate::opensymphony_orchestrator::ParentAttemptStatus::Failed + ); + assert_eq!( + scheduler + .executions() + .get(&parent_id) + .and_then(|execution| execution.retry()) + .and_then(|retry| retry.error.as_deref()), + Some("parent success lacked matching orchestrator-owned command evidence"), + "untrusted worker success is downgraded before scheduler retry classification" + ); + assert_eq!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::RefreshingRepositories + ); + + let tracker = FakeTracker { + active: vec![parent], + parent_evidence: Some(evidence), + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state should encode")), + ..Default::default() + }; + let mut restarted = Scheduler::new( + tracker, + workspace, + FakeWorker::default(), + scheduler_config(), + ); + restarted + .tick(ts(200)) + .await + .expect("restart should respect the durable dispatch claim"); + assert!(restarted.worker().launches.is_empty()); +} + +#[tokio::test] +async fn failed_parent_turn_without_commands_cleans_up_before_retry() { + let (mut scheduler, parent_id) = launched_parent_scheduler("FAILED-NO-COMMAND").await; + let first_run = scheduler.worker().launches[0].run.clone(); + let conversation = scheduler + .execution(&parent_id) + .and_then(|execution| execution.conversation()) + .cloned() + .expect("parent conversation"); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: first_run.worker_id.clone(), + outcome: WorkerOutcomeRecord::from_run( + &first_run, + WorkerOutcomeKind::Failed, + ts(150), + Some("runner failed before the first command".to_owned()), + None, + ), + }); + scheduler.tick(ts(150)).await.expect("record failed turn"); + + let retry_due_at = scheduler.executions()[&parent_id] + .retry() + .expect("retry") + .due_at; + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let controller = &state.parent_integrations[&parent_id]; + assert_eq!( + controller.attempts[0] + .cleanup + .as_ref() + .map(|cleanup| cleanup.status), + Some(crate::opensymphony_orchestrator::ParentCleanupStatus::Succeeded) + ); + assert_eq!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::RefreshingRepositories + ); + + let retry_dispatch_at = retry_due_at.max(ts(3_600_100)); + scheduler + .worker_mut() + .launch_results + .push_back(Ok(WorkerLaunch { + conversation: conversation.clone(), + started_at: Some(retry_dispatch_at), + })); + scheduler + .tick(retry_dispatch_at) + .await + .expect("retry after stopped failed turn"); + assert_eq!(scheduler.worker().launches.len(), 2); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + assert_eq!( + state.parent_integrations[&parent_id].current_attempt_id(), + Some("parent-attempt-2") + ); +} + +#[tokio::test] +async fn active_parent_cancellation_is_retryable_on_the_same_conversation() { + let (mut scheduler, parent_id) = launched_parent_scheduler("CANCELLED").await; + let first_run = scheduler.worker().launches[0].run.clone(); + let conversation = scheduler + .execution(&parent_id) + .and_then(|execution| execution.conversation()) + .cloned() + .expect("parent conversation"); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: first_run.worker_id.clone(), + outcome: WorkerOutcomeRecord::from_run( + &first_run, + WorkerOutcomeKind::Cancelled, + ts(150), + Some("runtime cancelled the active turn".to_owned()), + None, + ), + }); + scheduler.tick(ts(150)).await.expect("record cancellation"); + + let retry = scheduler.executions()[&parent_id] + .retry() + .cloned() + .expect("cancelled turn retry"); + assert_eq!(retry.reason, RetryReason::Cancelled); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + assert_eq!( + state.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::RefreshingRepositories + ); + + let retry_dispatch_at = retry.due_at.max(ts(3_600_100)); + scheduler + .worker_mut() + .launch_results + .push_back(Ok(WorkerLaunch { + conversation: conversation.clone(), + started_at: Some(retry_dispatch_at), + })); + scheduler + .tick(retry_dispatch_at) + .await + .expect("retry cancelled parent turn"); + assert_eq!(scheduler.worker().launches.len(), 2); + assert_eq!( + scheduler.worker().launches[1] + .expected_parent_conversation_id + .as_deref(), + Some(conversation.conversation_id.as_str()), + "the backend must reject a missing or different manifest before retry launch" + ); +} + +#[tokio::test] +async fn reopened_completed_parent_resets_controller_without_scope_change() { + let parent_id = IssueId::new("reopened-parent").expect("parent id"); + let child_id = IssueId::new("reopened-child").expect("child id"); + let mut parent = tracker_issue("reopened-parent", "COE-REOPENED", "In Progress", 0); + parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: "COE-REOPENED-CHILD".to_owned(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let mut snapshot = HierarchySnapshot::new(&parent); + snapshot.freeze().expect("freeze hierarchy"); + snapshot.mark_dispatched(); + let mut completed = crate::opensymphony_orchestrator::ParentIntegrationController::new( + parent_id.clone(), + snapshot.generation, + ) + .expect("controller"); + completed.state = crate::opensymphony_orchestrator::ParentIntegrationState::Completed; + completed.conversation_id = Some("old-parent-conversation".to_owned()); + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot)]), + parent_integrations: BTreeMap::from([(parent_id.clone(), completed)]), + ..Default::default() + }; + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state")), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + scheduler + .tick(ts(100)) + .await + .expect("reconcile reopened parent"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let controller = &state.parent_integrations[&parent_id]; + assert_eq!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::WaitingForChildren + ); + assert!(controller.conversation_id.is_none()); + assert_eq!(controller.hierarchy_generation, 1); +} + +#[tokio::test] +async fn hierarchy_reconciliation_preserves_reparented_leases_and_releases_terminal_roots() { + let child_id = IssueId::new("child").expect("child"); + let mut old_parent = tracker_issue("old-parent", "COE-OLD", "In Progress", 0); + old_parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: "COE-CHILD".to_owned(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + for reparented in [true, false] { + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { hierarchy: BTreeMap::from([( IssueId::new("old-parent").expect("parent"), HierarchySnapshot::new(&old_parent), @@ -1248,6 +2497,214 @@ async fn terminal_recovery_prunes_run_boundary_after_cleanup() { assert!(!state.run_started_at_by_issue.contains_key(&issue_id)); } +#[tokio::test] +async fn terminal_recovery_does_not_trust_success_before_parent_controller_finalization() { + let issue_id = IssueId::new("terminal-parent-recovery").expect("issue id"); + let recovered_worker_id = WorkerId::new("worker-terminal-parent-recovery").expect("worker id"); + let repository_id = CanonicalRepositoryId::new("github:parent-recovery").expect("repo"); + let child_id = IssueId::new("terminal-parent-child").expect("child id"); + let mut terminal_parent = + tracker_issue("terminal-parent-recovery", "COE-PARENT-TERMINAL", "Done", 0); + terminal_parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: "COE-PARENT-CHILD".to_owned(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let mut hierarchy = HierarchySnapshot::new(&terminal_parent); + hierarchy.mark_dispatched(); + let recovered_workspace = + workspace_record("COE-PARENT-TERMINAL", "/tmp/recovered/COE-PARENT-TERMINAL"); + let mut controller = + crate::opensymphony_orchestrator::ParentIntegrationController::new(issue_id.clone(), 1) + .expect("controller"); + controller + .admit("hierarchy:1", ts(1)) + .expect("admit parent"); + controller + .record_workspace_prepared( + [crate::opensymphony_orchestrator::ParentRepositoryTarget { + repository_id: repository_id.clone(), + checkout_handle: "checkout-parent".to_owned(), + relative_path: PathBuf::from("repositories/parent"), + target_commit: "target-parent".to_owned(), + }], + "targets:1", + ts(2), + ) + .expect("prepare parent"); + let attempt = controller + .start_attempt( + "parent integration", + "attempt:1", + crate::opensymphony_orchestrator::ParentAttemptRoot::ParentRoot, + "conversation-parent", + 100, + "targets:1", + ts(3), + ) + .expect("attempt"); + controller + .finish_attempt( + &attempt, + crate::opensymphony_orchestrator::ParentAttemptStatus::Passed, + Some(0), + crate::opensymphony_orchestrator::ParentCleanupReceipt { + status: crate::opensymphony_orchestrator::ParentCleanupStatus::Succeeded, + occurred_at: ts(4), + detail: None, + }, + ts(4), + ) + .expect("attempt receipt"); + assert_ne!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + ); + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(issue_id.clone(), hierarchy)]), + leases: vec![LeaseRecord { + kind: LeaseKind::AncestorIntegration, + resource: LeaseResource { + issue_id: child_id, + repository_id, + checkout_generation: "checkout-parent".to_owned(), + }, + owner: LeaseOwner::ancestor(&issue_id), + hierarchy_generation: 1, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + parent_integrations: BTreeMap::from([(issue_id.clone(), controller)]), + ..Default::default() + }; + let workspace = FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("state")), + recoveries: vec![RecoveryRecord { + issue: normalized_issue("terminal-parent-recovery", "COE-PARENT-TERMINAL", "Done"), + workspace: recovered_workspace, + successful_run: true, + cancelled_run: false, + completed_run: true, + had_in_flight_run: true, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: Some(RecoveredRun { + worker_id: recovered_worker_id.clone(), + conversation: conversation(&recovered_worker_id), + normal_retry_count: 0, + repository_binding: None, + }), + }], + ..Default::default() + }; + let mut scheduler = Scheduler::new( + FakeTracker { + terminal: vec![terminal_parent.clone()], + ..Default::default() + }, + workspace, + FakeWorker::default(), + scheduler_config(), + ); + + scheduler.bootstrap(ts(100)).await.expect("recovery"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + assert!(!state.terminal_orchestrator_issues.contains(&issue_id)); + assert!(state.leases.iter().any(LeaseRecord::active)); + assert!( + state.parent_integrations.contains_key(&issue_id), + "unfinalized controller was discarded: {state:?}" + ); + assert_ne!( + state.parent_integrations[&issue_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + ); + assert!( + scheduler.workspace().cleaned.is_empty(), + "unfinalized parent workspace was cleaned: {:?}", + scheduler.workspace().cleaned + ); + assert_eq!( + scheduler.executions()[&issue_id].status(), + SchedulerStatus::Released + ); + + let mut missing_controller_state = state; + missing_controller_state.parent_integrations.clear(); + let mut missing_controller_scheduler = Scheduler::new( + FakeTracker { + terminal: vec![terminal_parent], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some( + serde_json::to_value(&missing_controller_state).expect("missing-controller state"), + ), + recoveries: vec![RecoveryRecord { + issue: normalized_issue("terminal-parent-recovery", "COE-PARENT-TERMINAL", "Done"), + workspace: workspace_record( + "COE-PARENT-TERMINAL", + "/tmp/recovered/COE-PARENT-TERMINAL", + ), + successful_run: true, + cancelled_run: false, + completed_run: true, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: None, + }], + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + missing_controller_scheduler + .bootstrap(ts(101)) + .await + .expect("missing parent controller remains fenced"); + let missing_controller_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + missing_controller_scheduler + .workspace() + .durable_state + .clone() + .expect("missing-controller durable state"), + ) + .expect("decode missing-controller state"); + assert!( + !missing_controller_state + .terminal_orchestrator_issues + .contains(&issue_id) + ); + assert!( + missing_controller_state + .leases + .iter() + .any(LeaseRecord::active) + ); + assert!(missing_controller_scheduler.workspace().cleaned.is_empty()); +} + #[tokio::test] async fn active_ancestor_fence_is_checked_before_nested_parent_eligibility() { let root_id = IssueId::new("root-fence").expect("root id"); diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index bd2ec9113..3e1390f36 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -4,7 +4,10 @@ mod models; mod paths; pub use error::{WorkspaceError, WorkspaceOwnershipConflictDetails}; -pub use manager::{WorkspaceManager, compose_parent_prompt, compose_terminal_prompt}; +pub use manager::{ + WorkspaceManager, compose_parent_continuation_prompt, compose_parent_prompt, + compose_terminal_prompt, +}; pub use models::{ CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, CleanupOutcome, ConversationManifest, EnsureWorkspaceResult, HookConfig, HookDefinition, HookExecutionRecord, diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index 0452df888..e9eb6d230 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -4204,6 +4204,8 @@ impl WorkspaceManager { }; if decision == CleanupDecision::Remove { + self.unregister_parent_integration_worktrees(workspace) + .await?; match fs::remove_dir_all(workspace.workspace_path()).await { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} @@ -4222,6 +4224,53 @@ impl WorkspaceManager { }) } + async fn unregister_parent_integration_worktrees( + &self, + workspace: &WorkspaceHandle, + ) -> Result<(), WorkspaceError> { + let Some(_manifest) = self + .load_manifest::(workspace, &workspace.parent_manifest_path()) + .await? + else { + return Ok(()); + }; + let checkout_map = self + .load_manifest::(workspace, &workspace.child_checkouts_path()) + .await? + .ok_or_else(|| { + checkout_verification( + workspace.workspace_path(), + "parent cleanup requires its pinned child checkout map", + ) + })?; + for record in checkout_map.repositories.values() { + let source = self.validate_parent_retained_checkouts(record).await?; + let integration = + resolve_path_within_root(workspace.workspace_path(), &record.relative_path)?; + let integration_text = integration.to_str().ok_or_else(|| { + checkout_verification(&integration, "integration checkout path is not UTF-8") + })?; + let worktrees = self + .git( + source.workspace_path(), + &["worktree", "list", "--porcelain"], + ) + .await?; + let registered = worktrees.lines().any(|line| { + line.strip_prefix("worktree ") + .is_some_and(|path| path == integration_text) + }); + if registered { + self.git_with_isolated_hooks_and_global_config( + source.workspace_path(), + &["worktree", "remove", "--force", "--force", integration_text], + ) + .await?; + } + } + Ok(()) + } + pub async fn load_issue_manifest( &self, workspace: &WorkspaceHandle, @@ -5851,13 +5900,28 @@ pub fn compose_parent_prompt( } else { repositories }; + let verification_commits = envelope + .checkouts + .values() + .map(|checkout| { + format!( + " \"{}\": \"{}\"", + checkout.repository_id, checkout.target_commit + ) + }) + .collect::>() + .join(",\n"); format!( - "## Central Execution Procedure\n\n{central_procedure}\n\n## Parent Task Facts\n\n{task_facts}\n\n## Verified Child Checkout Map\n\nParent root: {}\nHierarchy generation: {}\n{}\n\n## Project-set Integration Instructions\n\n{}\n\n## Repository Instructions by Canonical ID\n\n{}\n\n## Runtime Capabilities\n\nharness={} cwd={} requested_scope={} containment={}\n", + "## Central Execution Procedure\n\n{central_procedure}\n\n## Parent Task Facts\n\n{task_facts}\n\n## Verified Child Checkout Map\n\nParent root: {}\nHierarchy generation: {}\n{}\n\n## Project-set Integration Instructions\n\n{}\n\n## Repository Instructions by Canonical ID\n\n{}\n\n## Final Verification Receipt\n\nRun the final integration check as one bounded foreground command from the parent root or one named checkout. Do not leave background processes running. After it exits, atomically write `evidence/final-verification.json` with this shape. `command` must be the exact shell command observed by the harness and `root` is `parent_root` or a verified checkout handle:\n\n```json\n{{\n \"schema_version\": 1,\n \"run_id\": \"{}\",\n \"attempt\": {},\n \"hierarchy_generation\": {},\n \"repository_commits\": {{\n{}\n }},\n \"command\": \"cargo test\",\n \"root\": \"parent_root\"\n}}\n```\n\nUse the exact inspected commits and the real verification command. The file only selects harness-observed evidence: OpenSymphony takes timing, exit status, bounded output, process ownership, and teardown from runtime command events. A successful harness turn or an unobserved command does not complete the parent.\n\n## Runtime Capabilities\n\nharness={} cwd={} requested_scope={} containment={}\n", envelope.workspace_path.display(), envelope.hierarchy_generation, checkout_map, integration, repositories, + envelope.run_id, + envelope.attempt, + envelope.hierarchy_generation, + verification_commits, envelope.harness, envelope.workspace_path.display(), envelope.requested_execution_scope, @@ -5865,6 +5929,33 @@ pub fn compose_parent_prompt( ) } +/// Current attempt facts for a reused parent conversation. This deliberately +/// excludes the full workflow and repository instructions already supplied to +/// the conversation, while refreshing the run-bound receipt contract. +pub fn compose_parent_continuation_prompt(envelope: &ParentRuntimeEnvelope) -> String { + let verification_commits = envelope + .checkouts + .values() + .map(|checkout| { + format!( + " \"{}\": \"{}\"", + checkout.repository_id, checkout.target_commit + ) + }) + .collect::>() + .join(",\n"); + format!( + "## Current Parent Verification Attempt\n\nThis continuation is bound to run `{}` attempt {} and hierarchy generation {}. Run the final integration check as one bounded foreground command from the parent root or one named checkout. After it exits, atomically write `evidence/final-verification.json` with the exact observed command and verified root:\n\n```json\n{{\n \"schema_version\": 1,\n \"run_id\": \"{}\",\n \"attempt\": {},\n \"hierarchy_generation\": {},\n \"repository_commits\": {{\n{}\n }},\n \"command\": \"cargo test\",\n \"root\": \"parent_root\"\n}}\n```\n\nThe file only selects harness-observed evidence. OpenSymphony takes timing, exit status, bounded output, process ownership, and teardown from runtime command events.\n", + envelope.run_id, + envelope.attempt, + envelope.hierarchy_generation, + envelope.run_id, + envelope.attempt, + envelope.hierarchy_generation, + verification_commits, + ) +} + fn parent_checkout_handle(repository_id: &str, hierarchy_generation: u64) -> String { let mut hasher = Sha256::new(); hasher.update(repository_id.as_bytes()); diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index c8f219d50..27aba9453 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -10,7 +10,8 @@ use crate::opensymphony_workspace::{ IssueContextArtifact, IssueDescriptor, IssueLifecycleState, ParentCheckoutRequest, ParentRuntimeDescriptor, PromptCaptureDescriptor, PromptKind, RunDescriptor, RunManifest, RunStatus, SessionContextArtifact, WorkspaceError, WorkspaceManager, WorkspaceManagerConfig, - compose_parent_prompt, compose_terminal_prompt, parent_workspace_key, + compose_parent_continuation_prompt, compose_parent_prompt, compose_terminal_prompt, + parent_workspace_key, }; use serde_json::json; use tempfile::TempDir; @@ -852,7 +853,58 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( ); assert!(prompt.contains(binding_a.repository_id().as_str())); assert!(prompt.contains("project-set instructions")); + assert!(prompt.contains("evidence/final-verification.json")); + assert!( + prompt.contains("A successful harness turn or an unobserved command does not complete") + ); assert!(!prompt.contains("frontend repository")); + let continuation = compose_parent_continuation_prompt(&codex); + assert!(continuation.contains("run-parent")); + assert!(continuation.contains("attempt 1")); + assert!(continuation.contains(binding_a.repository_id().as_str())); + assert!(continuation.contains("evidence/final-verification.json")); + assert!(!continuation.contains("generic lifecycle policy")); + assert!(!continuation.contains("project-set instructions")); + + let cleanup_parent = manager + .prepare_parent_execution_root(&parent, 9, repeat_requests.clone()) + .await + .expect("cleanup parent generation should prepare"); + let cleanup_worktrees = cleanup_parent + .child_checkout_map + .repositories + .values() + .map(|checkout| { + cleanup_parent + .handle + .workspace_path() + .join(&checkout.relative_path) + }) + .collect::>(); + manager + .cleanup_failed_terminal_workspace(&cleanup_parent.handle) + .await + .expect("terminal parent cleanup should unregister integration worktrees"); + assert!(!cleanup_parent.handle.workspace_path().exists()); + let registered_worktrees = [ + child_a1.handle.workspace_path(), + child_b.handle.workspace_path(), + child_c.handle.workspace_path(), + ] + .into_iter() + .map(|source| git(source, &["worktree", "list", "--porcelain"])) + .collect::>() + .join("\n"); + for worktree in &cleanup_worktrees { + assert!( + !registered_worktrees.contains(worktree.to_string_lossy().as_ref()), + "terminal cleanup must remove the Git worktree registration" + ); + } + manager + .prepare_parent_execution_root(&parent, 9, repeat_requests.clone()) + .await + .expect("the same generation should recreate after terminal cleanup"); #[cfg(unix)] { diff --git a/docs/architecture.md b/docs/architecture.md index 6aa9c65ce..7874b6f4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,6 +108,62 @@ once with this root as `cwd`, a relative checkout-handle map, and a generic `parent_multi_checkout` envelope; neither the directory layout nor the prompt assigns repository roles. +The scheduler persists one generation-bound parent integration controller with +the hierarchy state. Its versioned transitions cover admission, lease +acquisition, workspace preparation, repository refresh, harness integration, +final verification, and finalization. Each harness turn is one bounded attempt +on the shared parent conversation and records its input version, root or opaque +checkout handle, redacted log tail, cleanup receipt, and outcome. Recovery keeps +a reconciled running attempt attached; an unreconciled attempt becomes +indeterminate and must pass cleanup and repository refresh before rerun. Final +verification binds the worker-authored exact command to a trusted SHA-256 +identity before redaction; durable evidence retains that identity and a redacted +diagnostic, while events older than the current attempt are ignored. Final +admission identity is stored separately from the compact transition tail so an +unbounded retry history cannot replay the initial admission transitions. If a +completed parent reopens, the scheduler creates a new controller lifecycle even +when its child-edge generation did not change. A bound parent conversation also +fixes the harness choice for that lifecycle; a configured harness switch fails +before the replacement session starts. +Final evidence is accepted only from the run-bound +`evidence/final-verification.json` receipt. The runtime reopens every checkout +at its exact prepared commit and uses the file only to select an actual command +observed through the Codex or OpenHands event stream. The controller maps the +observed command working directory to the parent root or an exact checkout +handle and supplies the deadline, exit result, bounded log, +foreground-process ownership, and +teardown from those runtime events before it can pass the attempt. Generic +harness success or a prompt-authored claim without matching events is +insufficient. Each accepted command or resource event is persisted with the +controller before the worker reaches a terminal outcome. On timeout or cancellation, a reconciled harness stopped state +releases the foreground-process receipt; any other named resource remains an +explicit cleanup fence. The durable final record maps every canonical repository to the +exact verified commit so a higher ancestor can consume the completed parent +without assigning repository roles. +When admission produces no repository targets, the same observed final command +can complete with an empty commit map. This represents a repository-neutral +parent and does not weaken command, deadline, cleanup, or controller gates. +Recovery treats a persisted launch intent with no attached conversation, +command, or resource as a metadata-only crash and safely returns through +cleanup and baseline refresh. A terminal harness manifest whose controller +outcome was lost becomes indeterminate and reruns after resource cleanup and +baseline refresh. Recovered parent memory grants restore the bearer +already held by the bound conversation into the reconstructed registry. +Legacy in-flight parent runs that predate controller persistence reconstruct +and persist that controller from the durable hierarchy, workspace envelope, +run identity, and existing conversation before backend reattachment. Route +preview conversations never become controller bindings. +Every recovered parent dispatch carries that expected conversation identity +into the worker launch boundary. A missing or different conversation manifest +fails before a replacement harness session can start. Reused turns receive a +small continuation prompt containing the current run, attempt, generation, +exact commit map, and receipt contract; the original workflow and repository +instructions remain in the bound conversation rather than being replayed. +Terminal success is gated by the durable controller's completed state in both +live and recovery release paths. Completed parent roots and their descendant +leases remain durable for capture retry; OSYM-893 consumes that terminal state +for ordered worktree cleanup and lease release. + ### 3.2 OpenHands is the execution adapter OpenHands provides: diff --git a/docs/configuration.md b/docs/configuration.md index e6e3a9cb0..109739ad3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -813,6 +813,14 @@ When archive succeeds and the repo uses the managed local OpenHands server, OpenSymphony also moves the issue's persisted conversation from the repo-scoped `active/` store to `archived/`. +When `memory.serve` uses the default `127.0.0.1:0` bind, the first successful +run records the selected loopback address in +`/.opensymphony-memory-bind.json`. Later daemon starts reuse +that exact address so a recovered parent conversation keeps the memory endpoint +associated with its restored bearer. If the recorded port is unavailable or +the configured interface changes, startup fails instead of silently assigning +a different endpoint. An explicit nonzero `memory.bind` remains unchanged. + Initialize the shared memory policy and learned ontology file with: ```bash diff --git a/docs/memory.md b/docs/memory.md index 0b581882a..e34777862 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -227,8 +227,13 @@ memory: `auto_capture` defaults to `true`. `auto_archive` defaults to `false`; when it is enabled, OpenSymphony archives only after fresh capture succeeds with no blocking warnings. `serve` starts the local memory server during -`opensymphony run` when memory is initialized. The default bind address uses an -ephemeral loopback port, and workers receive the resulting MCP endpoint through +`opensymphony run` when memory is initialized. The default bind address selects +an available loopback port once, records it in +`/.opensymphony-memory-bind.json`, and reuses that exact port on +later starts. This keeps the endpoint stable for an authenticated parent +conversation recovered after daemon restart; startup fails if the recorded +port cannot be rebound rather than rotating the conversation's endpoint. +Workers receive the resulting MCP endpoint through `OPENSYMPHONY_MEMORY_ENDPOINT`. Workers receive only the normal read token; admin tools require a separate `OPENSYMPHONY_MEMORY_ADMIN_TOKEN`. @@ -481,7 +486,13 @@ explicitly. Ordinary worker grants have no administrative capability. Persisted sibling memory and target-branch code use the registered canonical source; AST requests may name that source using the `repository` alias as well as the legacy `repo` field; live overlays resolve only the execution repository's -verified checkout. +verified checkout. A parent integration grant instead contains the exact +repository set from its durable runtime envelope. Persisted memory may span +that set and the parent plus recorded descendants; `all_accessible` cannot add +another repository or work item. Live overlays resolve only the envelope's +active integration checkouts, using their opaque handles, contained relative +paths, run and attempt, and target commits. An unrelated managed checkout stays +inaccessible even when its repository belongs to the same project. The overlay must match the worker's issue, run, attempt, checkout generation, and target commit. A worker may advance that checkout during its run; strict discovery verifies that the current `HEAD` descends from the target commit @@ -504,23 +515,43 @@ providers to import memory internals. Worker-scoped memory access uses a server-local, non-persisted bearer grant bound to the worker's project-set, project, work item, execution repository, authorized repository set, visibility, run/attempt, and checkout generation. -An unchanged claim set may reuse the conversation bearer; a changed run, -attempt, binding, target commit, or generation rotates the bearer and requires a -fresh conversation. The startup checkout `HEAD` is retained as capture -provenance, not as a live-overlay equality requirement. Durable daemon recovery reconstructs -the claims from the terminal runtime envelope and forces a fresh conversation -when the in-memory registry was replaced. Terminal, inactive, and -binding-superseded lifecycles issue the stop/cancel fence before revoking the -issue grant. Raw bearer tokens are never persisted in manifests or diagnostics. +An unchanged leaf claim set may reuse the conversation bearer; a changed leaf +run, attempt, binding, target commit, or generation rotates the bearer and +requires a fresh leaf conversation. The startup checkout `HEAD` is retained as +capture provenance, not as a live-overlay equality requirement. Durable daemon +recovery reconstructs leaf claims from the runtime envelope and forces a fresh +leaf conversation when the in-memory registry was replaced. A parent controller +instead restores the bearer already held by its persisted OpenHands +conversation into the reconstructed registry and verifies the same memory +endpoint and workspace before reattachment. A missing or different parent +conversation manifest is rejected before a replacement session can launch. +Terminal, inactive, and binding-superseded lifecycles issue the stop/cancel +fence before revoking the issue grant. Raw bearer tokens are never persisted in +manifests or diagnostics. With automatic capture enabled, terminal issues seen +at daemon startup remain capture candidates instead of being assumed captured. This grant applies to direct `memory.show` capsule reads as well as search, context, brief, related, docs, status, and code-intelligence tools. If the central service stops, the control-plane status is explicitly degraded and scoped worker reads remain blocked; they do not silently fall back to an unrelated repository-local store. Leaf capture reads the immutable runtime envelope for repository ownership and commits, and documentation sync uses -that explicit owner. Terminal capture snapshots those durable envelopes before -the scheduler performs terminal workspace cleanup, so removal or retention -policy cannot erase the repository/run provenance needed for capture. +that explicit owner. Terminal capture reads those durable envelopes from leaf +roots and from the generation-bound `parents//` layout. +Completed parent roots remain retained across reconciliation and restart so a +transient capture failure cannot erase the repository/run provenance needed for +the next attempt. Their descendant leases remain active with the root so leaf +cleanup cannot invalidate registered integration worktrees before OSYM-893 +records capture acknowledgement and performs ordered cleanup. +For a parent, the capture path also reads validated durable controller state +and requires the matching lifecycle to be `completed`, its final attempt to be +passed for the same run and input version, its final evidence to be bound to the +same conversation, and its exact repository commit map to equal the runtime +envelope. A terminal run manifest or harness success by itself cannot authorize +parent capture. The durable binding explicitly marks a parent even when this +verified commit map is empty, so repository-neutral capture cannot inherit a +configured default leaf repository. That empty-target capture still records one +repository-neutral parent-runtime source reference with the durable run and +attempt identifiers. Retained legacy run envelopes that lack usable run/attempt provenance are skipped as non-bindable entries during the pre-cleanup scan rather than preventing unrelated terminal captures from completing. diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index eaf063dbb..ef748f878 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -63,6 +63,24 @@ Why fakes matter: - disconnect and reconnect behavior - server restart scenarios - scheduler recovery on daemon restart +- parent integration launch-intent persistence before worker start, exact + run-bound final-verification receipts, missing/stale receipt rejection, + exact command hashing before durable redaction, prior-attempt event rejection, + production-shaped Codex and OpenHands command start/completion events, + observed parent-root and checkout working directories, orchestrator-owned + deadlines, bounded command logs/resources, rejection of prompt-authored + execution claims, uncertain cleanup fences, metadata-only prelaunch crash + recovery, authenticated same-conversation parent grants after restart, + prelaunch rejection of missing or changed bound conversation manifests and + parent harness switches, attempt-specific continuation prompts for Codex and + OpenHands, terminal-turn cleanup before retry, retryable active cancellation, + completed-parent reopen with unchanged child edges, compacted admission + idempotency, empty-target verification, recursive intermediate-descendant + memory grants, durable-controller-gated parent capture, stable default memory + endpoint recovery, event-only command-receipt persistence, dry-run preview + isolation, legacy missing-controller migration before same-conversation + reattachment, terminal-success finalization gates, and retry after a failed + durable outcome write ## 2.4 Live local tests @@ -90,6 +108,8 @@ Current implementation: - `cargo test --test linear_client` exercises fixture-backed GraphQL normalization, parent/child hierarchy extraction, personal-API-key auth headers, required API-key/project/state configuration validation, issue URL/raw-priority preservation, full label pagination, raw workflow-state type preservation alongside normalized kinds, non-archived candidate polling, lightweight dispatch-summary reads, archived terminal cleanup reads, archived by-ID state refresh, GraphQL 400/429 rate-limit retries including reset-header handling, long rate-limit reset return-without-sleep behavior with the 30s inline cap, retryable 5xx GraphQL error envelopes, project-scoped by-ID state refresh with bounded unscoped fallback for moved issues, and tracker error mapping against a local stub server - `cargo test --test linear_client live_linear_client_reads_opensymphony_project -- --ignored --nocapture` is a read-only live evidence probe for PRs that need to show the actual Linear HTTP/GraphQL client path against the OpenSymphony project - `cargo test --test hierarchy_selection --test scheduler` exercises blocker-aware and hierarchy-aware dispatch filtering, child-edge generation reconciliation and freeze fencing, owner-identified lease acquisition, provider-evidence parent eligibility, leaf-before-parent ordering, cached per-state capacity limiting, continuation retry, exponential failure backoff, runtime-event-fed stall detection, terminal cleanup/release, active-state reconciliation, Linear cooldown behavior, separated Linear polling cadences, manifest-backed workspace recovery against fake tracker/workspace/worker backends, external retry-marker proof, metadata-only workspace recovery, binding-drift workspace rematerialization, legacy parent neutrality, same-ID generation retention, independent running/discovery cadences, and adoption of every launched worker after persistence errors +- `cargo test --lib parent_integration` exercises the restart-safe parent state machine, exact multi-repository and empty-target evidence, topology-neutral checkout handles, idempotent admission after transition compaction, bounded diagnostics, timeout and indeterminate cleanup gates, resource collisions, and higher-ancestor serialization +- the memory scope and terminal capture tests cover parent grants across exact descendants, denial of unrelated repositories and work items, parent runtime-envelope capture, and preservation of every verified repository commit - `cargo test --lib orchestrator_run::backends::tests` covers runtime workspace-manifest recovery, in-flight run detection from `run.json`, and launch-path failure handling in the concrete CLI adapter - `cargo test --test workspace_manager` covers durable checkout/staging ownership-marker sweeps, preservation of foreign generation-shaped directories and staging content, receipt-owned recovery, and the retry verification mode that permits legitimate worker changes while retaining checkout provenance checks - `cargo test --lib opensymphony_workspace::manager::tests::discover_agents` and the memory scope tests cover bounded tracked-instruction probes, failure propagation, canonical project-ID filtering, and project-scoped direct capsule reads @@ -518,7 +538,9 @@ parent identifiers remain distinct; and `after_create` cannot turn a parent root into a Git repository. It also covers parent `after_create` receipt/reuse/failure behavior and proves a child-controlled direct or worktree-conditional fsmonitor executable is rejected without running before -the incomplete parent root is rolled back. A lower-level regression proves the +the incomplete parent root is rolled back. Terminal cleanup also unregisters +the real integration worktrees and proves the same generation can be prepared +again without a stale Git registration. A lower-level regression proves the orchestrator Git command overrides checkout-controlled fsmonitor configuration and default repository hooks at execution time. Focused OpenHands and Codex tests bind the same logical `parent_multi_checkout` envelope diff --git a/docs/websocket-runtime.md b/docs/websocket-runtime.md index 5de96bfb3..b38a6b83a 100644 --- a/docs/websocket-runtime.md +++ b/docs/websocket-runtime.md @@ -207,6 +207,9 @@ Current repository implementation: - `OpenHandsClient::search_all_events` paginates until `next_page_id` is absent - `EventCache` deduplicates by event ID, inserts by timestamp order, and can return the newly merged events from reconcile or reconnect passes - `RuntimeEventStream` preserves one cache across reconnect cycles and replays ordered state updates into the state mirror after late arrivals +- the issue-session bridge snapshots known event IDs before each reconcile and + forwards every newly inserted event in cache order, so a command start and + its later completion cannot collapse into only the final observation - the contract suite includes multi-page reconciliation, out-of-order insertion, and reconnect-recovery tests ## 6.4 Conversation state mirror diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 090048bec..f23d24f40 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -192,6 +192,32 @@ one repository share one handle; the target must contain every provider merge-result commit, while replaced feature commits from squash or rebase are not required. +Parent integration attempts execute only in the parent root or in a checkout +named by `child-checkouts.json`. The durable controller records an intent before +each state-changing operation and stores its receipt before advancing. Attempt +timeouts, interrupt uncertainty, resource collisions, and cleanup failures are +retained with bounded diagnostics. After restart, an attempt without a +reconciled harness is marked indeterminate; repository refresh cannot proceed +until its recorded resources are released and cleanup succeeds. An empty +resource list does not prove teardown after an uncertain restart. The attempt +intent is persisted before the worker starts, and the conversation identity is +attached as a later launch receipt, so a crash cannot leave an unowned parent +harness. Cleanup and workspace deletion policy remain owned by the workspace +manager. +Once that conversation is bound, a parent harness change is rejected before a +replacement session starts. A runtime-terminal successful, failed, or canceled +turn supplies stopped-turn evidence and releases its foreground-process receipt; +timeout, stall, detach, and failed-cancel paths still require explicit stopped +state reconciliation. An ordinary cancellation while the tracker parent remains +active returns through cleanup and baseline refresh. Operator and tracker +terminal cancellation remains terminal. + +The final-verification receipt selects an exact harness-observed foreground +command. The trusted loader computes its SHA-256 identity from the transient +exact text, then persists only the identity and a bounded redacted diagnostic. +Command start and completion events preceding the current attempt's start time +cannot contribute to its result. + The initial launch requires the integration worktrees to match their prepared targets exactly. A continuation or failure retry may reattach when the recorded target remains an ancestor of the current integration HEAD, preserving @@ -205,6 +231,59 @@ and prompt content both exclude its front matter. Project-set integration instructions are also re-read and hash-checked after `before_run`; the parent prompt is composed only from those final verified bytes. +Before a fresh parent turn, the runtime removes any stale +`evidence/final-verification.json`. The parent writes a new bounded selector for +the command whose result should count as final verification. At completion the +runtime requires a regular file no larger than 64 KiB, checks its run, attempt, +hierarchy generation, command root, and exact repository commit map, and +reopens the checkouts at those commits. The selected command must match start +and completion events observed from the Codex or OpenHands runtime. Those +events, rather than fields supplied by the parent, provide the orchestrator's +deadline, working directory, exit result, bounded output, foreground-process +ownership, and teardown receipt. A reported working directory maps only to the +parent root or an exact verified checkout path, and the selector must name that +observed root. Missing, stale, unobserved, late, or still-active evidence +converts a generic successful harness turn into a failed parent attempt. +An acknowledged interrupt counts as foreground-process teardown only after the +harness has reconciled a stopped state. It does not release separately named +ports or resources, which continue to fence refresh and retry until their own +cleanup receipts succeed. +After a daemon restart, a parent memory grant restores the bearer already held +by the bound OpenHands conversation into the reconstructed registry, preserving +the one-conversation controller binding. An attempt +intent persisted before worker launch has no process to tear down; recovery +records that fact and refreshes the verified baseline before retrying. A +terminal harness manifest with no durable controller outcome proves foreground +teardown but never proves verification success; the attempt becomes +indeterminate and reruns after named-resource cleanup and baseline refresh. Terminal +tracker state cannot publish orchestrator success, release descendant leases, +or remove the parent workspace until the durable controller reaches +`completed` through accepted final verification. +A verified passed attempt remains parked while the tracker still reports the +parent active or its refresh is unavailable. Restart recovery preserves that +tracker-confirmation wait and does not launch a second attempt. A completed +controller authorizes terminal success only while its hierarchy generation +still matches the current unblocked snapshot. COE-554 retains the completed +parent root and its evidence-protecting leases across later reconciliation and +daemon restart so automatic capture can retry without losing its runtime +envelope or source checkouts. OSYM-893 owns durable capture acknowledgement, +ordered lease release, cleanup intent, tombstones, and deletion. +A recovered parent worker must find the exact conversation manifest recorded by +the controller. Its expected identity crosses the scheduler-to-worker request, +and a missing or different manifest fails before any harness session is +created. The reused conversation receives current run-bound receipt guidance +without replaying the full workflow prompt. When terminal cleanup removes a +parent root, the manager first unregisters each contained integration worktree +from its retained source repository with isolated Git configuration. Cleanup is +idempotent after partial progress, and the same hierarchy generation can be +materialized again without stale Git worktree registrations. +A reopened completed parent starts a new controller lifecycle even when the +child-edge generation is unchanged. Admission idempotency is retained outside +the compact transition history, so retry history pruning cannot replay initial +admission. Parents with no recorded checkout targets still require an observed +successful final command and successful cleanup; their final repository commit +map is empty by definition. + The parent runtime artifact records the complete relative checkout map, requested `parent_multi_checkout` scope, harness/model selection, and truthful `trusted_host` or `workspace_confined` containment. Parent memory grants remain From ee345287cbef2d0051d16da79d569a7a4066896c Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 12 Sep 2026 15:49:52 -0500 Subject: [PATCH 05/29] docs(memory): capture COE-554 and sync project docs Record the merged parent integration controller and refresh managed source references using the complete project memory corpus. Validation: memory lint --public-docs and git diff --check passed. --- .opensymphony/memory/memory.yaml | 27 +++++++++++++++++++++++++++ docs/accessibility.md | 2 ++ docs/build.md | 2 ++ docs/cli.md | 2 ++ docs/code-intelligence.md | 2 ++ docs/code-review.md | 2 ++ docs/codex-runtime.md | 2 ++ docs/codex.md | 2 ++ docs/configuration.md | 2 ++ docs/control.md | 2 ++ docs/data-contracts.md | 4 +++- docs/debugging.md | 2 ++ docs/desktop.md | 2 ++ docs/developer-experience.md | 2 ++ docs/docs.md | 2 ++ docs/documentation.md | 2 ++ docs/duckdb.md | 2 ++ docs/evidence.md | 4 +++- docs/experiments.md | 3 +++ docs/frontend.md | 2 ++ docs/gateway.md | 2 ++ docs/graph-view.md | 2 ++ docs/harness-runtime.md | 2 ++ docs/harness.md | 3 +++ docs/installer.md | 2 ++ docs/linear.md | 2 ++ docs/mcp.md | 2 ++ docs/memory.md | 2 ++ docs/migration.md | 2 ++ docs/model-configuration.md | 2 ++ docs/okf.md | 2 ++ docs/openhands-runtime.md | 2 ++ docs/operations.md | 2 ++ docs/orchestrator.md | 2 ++ docs/outcomes.md | 3 +++ docs/planning.md | 2 ++ docs/process-protocol.md | 3 +++ docs/provenance.md | 3 +++ docs/quality.md | 4 +++- docs/query-packs.md | 2 ++ docs/release.md | 2 ++ docs/repository-layout.md | 2 ++ docs/run-detail.md | 2 ++ docs/runner.md | 3 +++ docs/runtime.md | 2 ++ docs/rust.md | 2 ++ docs/scheduler.md | 3 +++ docs/security.md | 2 ++ docs/target-repo-bootstrap.md | 2 ++ docs/task-packets.md | 3 +++ docs/template.md | 2 ++ docs/testing-and-operations.md | 2 ++ docs/testing.md | 2 ++ docs/tests.md | 2 ++ docs/threejs.md | 2 ++ docs/tui.md | 2 ++ docs/ui.md | 2 ++ docs/verifiers.md | 3 +++ docs/web.md | 2 ++ docs/workflow.md | 2 ++ docs/workspace-lifecycle.md | 2 ++ docs/workspace.md | 3 +++ 62 files changed, 162 insertions(+), 3 deletions(-) diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index 86395e30a..6b4a4910c 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -815,6 +815,7 @@ areas: - area:evidence - area:experiments - area:harness + - area:memory - area:orchestrator - area:outcomes - area:process-protocol @@ -823,6 +824,7 @@ areas: - area:runner - area:scheduler - area:task-packets + - area:testing - area:verifiers - area:workspace - area:workspace-lifecycle @@ -1395,6 +1397,7 @@ areas: - area:evidence - area:experiments - area:harness + - area:memory - area:orchestrator - area:outcomes - area:process-protocol @@ -1403,6 +1406,7 @@ areas: - area:runner - area:scheduler - area:task-packets + - area:testing - area:verifiers - area:workspace - area:workspace-lifecycle @@ -1422,8 +1426,10 @@ areas: linear_labels: - area:experiments - area:harness + - area:memory - area:orchestrator - area:scheduler + - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -1665,7 +1671,9 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator + - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -2751,8 +2759,10 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator - area:outcomes + - area:testing - area:verifiers - area:workspace-lifecycle linear_milestones: @@ -2856,9 +2866,11 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator - area:process-protocol - area:runner + - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -2874,9 +2886,11 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator - area:provenance - area:task-packets + - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -2894,6 +2908,7 @@ areas: - area:evidence - area:experiments - area:harness + - area:memory - area:orchestrator - area:outcomes - area:process-protocol @@ -2902,6 +2917,7 @@ areas: - area:runner - area:scheduler - area:task-packets + - area:testing - area:verifiers - area:workspace - area:workspace-lifecycle @@ -3149,8 +3165,10 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator - area:runner + - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3323,8 +3341,10 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator - area:scheduler + - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3532,8 +3552,10 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator - area:task-packets + - area:testing - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3720,6 +3742,7 @@ areas: - 'M12.6: Tree-sitter Code Intelligence' - 'M12.85: Codex Thread Lifecycle' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M6: Gateway And Stream Contract' - 'M7: Shared Client And Desktop Alpha' @@ -4063,7 +4086,9 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator + - area:testing - area:verifiers - area:workspace-lifecycle linear_milestones: @@ -4234,7 +4259,9 @@ areas: source_refs: linear_labels: - area:harness + - area:memory - area:orchestrator + - area:testing - area:workspace - area:workspace-lifecycle linear_milestones: diff --git a/docs/accessibility.md b/docs/accessibility.md index e5c7fca45..d1aa73546 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/build.md b/docs/build.md index 3f1bf4953..5c0168311 100644 --- a/docs/build.md +++ b/docs/build.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/cli.md b/docs/cli.md index cd40d453c..2d2bc7c44 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index f82de76a3..8460113a7 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -274,6 +274,7 @@ references, and never returns source snippets. - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -330,6 +331,7 @@ references, and never returns source snippets. - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-review.md b/docs/code-review.md index bcdf2ab0a..d7d90b106 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index 9376bf960..a90506c4b 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex.md b/docs/codex.md index e01201c1f..4451a804c 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/configuration.md b/docs/configuration.md index 109739ad3..e607350a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1078,6 +1078,7 @@ copy that guide into the target repository. - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1232,6 +1233,7 @@ copy that guide into the target repository. - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/control.md b/docs/control.md index 6ac8b0248..0e5fb4d30 100644 --- a/docs/control.md +++ b/docs/control.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 5eb270db9..45f38fb95 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -12,11 +12,11 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. -- COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +34,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -44,6 +45,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 ## Source refs - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/debugging.md b/docs/debugging.md index 55ef6473a..42be34111 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/desktop.md b/docs/desktop.md index b42b72747..18e87b429 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index a8b4ba9d5..f7907f33f 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/docs.md b/docs/docs.md index 3d4c11f02..2d0735ad0 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -219,6 +220,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/documentation.md b/docs/documentation.md index 0cb87163f..bbe76be61 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/duckdb.md b/docs/duckdb.md index 3eb8d36fd..bcb435830 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/evidence.md b/docs/evidence.md index 8659f169a..a9bd51a16 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -12,11 +12,11 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. -- COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +34,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -44,6 +45,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 ## Source refs - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/experiments.md b/docs/experiments.md index 8ed5aee87..3e2b00f8c 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-566: Implement configurable run matrices and scheduling ## Source refs - COE-553 +- COE-554 - COE-566 diff --git a/docs/frontend.md b/docs/frontend.md index 2533fe5f2..50c04728d 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/gateway.md b/docs/gateway.md index bf1009758..602350898 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -142,6 +142,7 @@ baseline, not an issue-workspace overlay. - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -240,6 +241,7 @@ baseline, not an issue-workspace overlay. - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/graph-view.md b/docs/graph-view.md index ad9fab0cb..c2602a5bc 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index c14bf81a8..0a1436477 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness.md b/docs/harness.md index 6eaf82573..f36bf4b38 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) ## Important invariants @@ -29,9 +30,11 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller ## Source refs - COE-553 +- COE-554 diff --git a/docs/installer.md b/docs/installer.md index d1ad92939..c818313d7 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/linear.md b/docs/linear.md index 20777688d..0c6e075e4 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/mcp.md b/docs/mcp.md index c58c7deaf..7c9961fd3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/memory.md b/docs/memory.md index e34777862..3ae6b5506 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -815,6 +815,7 @@ are covered by the same archive operation. - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -977,6 +978,7 @@ are covered by the same archive operation. - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/migration.md b/docs/migration.md index 8c6c57cfb..45ae278ef 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index de161fec4..fa49262fe 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/okf.md b/docs/okf.md index 2087c9c30..ab36c4799 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index ca1ec2979..860441aef 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/operations.md b/docs/operations.md index 2ebcee5af..03f1cacb7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -970,6 +970,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1135,6 +1136,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index e2a4aa979..af410fcb0 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/outcomes.md b/docs/outcomes.md index 112f1060f..9f2df1a3d 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-564: Implement verifier execution and outcome records ## Source refs - COE-553 +- COE-554 - COE-564 diff --git a/docs/planning.md b/docs/planning.md index 7800906fb..d9766c325 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index c7216fb6d..08d9c5389 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs - COE-553 +- COE-554 - COE-567 diff --git a/docs/provenance.md b/docs/provenance.md index 60bc3daff..671d2dc95 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-563 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-563: Implement task-packet admission and freeze tooling ## Source refs - COE-553 +- COE-554 - COE-563 diff --git a/docs/quality.md b/docs/quality.md index 113b774e3..92d4f71c8 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -12,11 +12,11 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. -- COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +34,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -44,6 +45,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 ## Source refs - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/query-packs.md b/docs/query-packs.md index 83c590482..6f91776a6 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/release.md b/docs/release.md index dde15bfe6..da4e637fd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 44f849b4d..719798a00 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -247,6 +247,7 @@ of dead bridge code. - COE-507: Deduplicate query-pack assets for grammar variants - COE-508: Cache code-intel parsers and compiled query packs - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller ## Source refs @@ -309,5 +310,6 @@ of dead bridge code. - COE-507 - COE-508 - COE-553 +- COE-554 diff --git a/docs/run-detail.md b/docs/run-detail.md index fabc561b9..cb18ca6ea 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/runner.md b/docs/runner.md index 5e70798df..aa2c8a56d 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs - COE-553 +- COE-554 - COE-567 diff --git a/docs/runtime.md b/docs/runtime.md index 4e40bb3ee..435ea8331 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/rust.md b/docs/rust.md index 870f15194..488d5bbfc 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/scheduler.md b/docs/scheduler.md index a9654d64c..dc7483c59 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-566: Implement configurable run matrices and scheduling ## Source refs - COE-553 +- COE-554 - COE-566 diff --git a/docs/security.md b/docs/security.md index 2c4f5a6bc..3fcf8197e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index 953dede2f..4aae2db83 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/task-packets.md b/docs/task-packets.md index 0a662b99f..7d91e9daf 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-563 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. @@ -31,12 +32,14 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-563: Implement task-packet admission and freeze tooling - COE-565: Implement isolated workspace materialization ## Source refs - COE-553 +- COE-554 - COE-563 - COE-565 diff --git a/docs/template.md b/docs/template.md index 7a94de1e2..c16fca582 100644 --- a/docs/template.md +++ b/docs/template.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index ef748f878..15b717b8d 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -778,6 +778,7 @@ That document covers: - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-554: Restart-Safe Parent Integration Controller ## Source refs @@ -927,5 +928,6 @@ That document covers: - COE-549 - COE-550 - COE-551 +- COE-554 diff --git a/docs/testing.md b/docs/testing.md index 62818b55c..2a67c1d9b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -119,6 +119,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/tests.md b/docs/tests.md index 748222d3c..059694ce5 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/threejs.md b/docs/threejs.md index 0cf9c8d89..e7508fde9 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/tui.md b/docs/tui.md index 9a4fb4d31..4ff2f41ee 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -208,6 +209,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/ui.md b/docs/ui.md index fcdcc22b5..20ef984fc 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -116,6 +116,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/verifiers.md b/docs/verifiers.md index 6bd875c95..fa0502713 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-564: Implement verifier execution and outcome records ## Source refs - COE-553 +- COE-554 - COE-564 diff --git a/docs/web.md b/docs/web.md index bcac0fb98..82bd0316d 100644 --- a/docs/web.md +++ b/docs/web.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/workflow.md b/docs/workflow.md index 054a48362..43c7f34aa 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index 551893881..7ade23ec7 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -77,6 +77,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -130,6 +131,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-550 - COE-551 - COE-553 +- COE-554 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace.md b/docs/workspace.md index 755ff55d4..01eb7cd80 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 ## Current model - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) +- COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-565 contributed: No merged PR source was matched during capture. ## Important invariants @@ -30,11 +31,13 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 ## Recent changes - COE-553: Parent Execution Roots And Child Workspace Reuse +- COE-554: Restart-Safe Parent Integration Controller - COE-565: Implement isolated workspace materialization ## Source refs - COE-553 +- COE-554 - COE-565 From 52d3ba37b271443ecfc40d9cfdfa67728bbfef31 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sun, 13 Sep 2026 01:25:00 -0500 Subject: [PATCH 06/29] COE-555: Add parent repair review and merge lifecycle (#238) * feat: add durable parent repair lifecycle * fix(orchestrator): harden parent repair recovery flow Drive failed parent verification through the production scheduler repair lifecycle, pin provider policy and target provenance, and reconcile current-head review and push evidence safely across retries and restarts.\n\nAdd regression coverage for the real scheduler/backend round trip, schema-one state migration, requested-change deduplication, force-push protection, and refreshed instruction provenance. * fix(orchestrator): harden parent repair recovery * fix(orchestrator): harden parent repair recovery * fix(parent): harden repair replay and merge authority Summary: - preserve pre-write Codex review cursors and reconcile branch receipts - requeue no-commit repair turns and reject invalid review-provider routes - require current policy evidence and a pending orchestrator merge intent - add focused crash, no-change, configuration, and external-merge tests Rationale: - prevent duplicate billed scans and permanently stalled repair attempts - keep provider reconciliation subordinate to durable scheduler authority Tests: - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --lib - GIT_CONFIG_GLOBAL=/dev/null cargo test-system-duckdb --test scheduler - GIT_CONFIG_GLOBAL=/dev/null cargo check-system-duckdb - GIT_CONFIG_GLOBAL=/dev/null cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(parent): bind merge intents to repair heads * fix(parent): close review and repair policy gaps * fix: preserve bounded parent repair review recovery * fix: close parent repair provider lifecycle gaps * fix: authenticate parent repair review evidence * fix: retain unresolved repair review evidence * fix(parent): require terminal harness evidence for release * fix(orchestrator): retain indeterminate parent harness ownership * fix(orchestrator): fence unreconciled parent repairs * fix(orchestrator): close parent repair terminal fences * fix(parent): honor repair review policy * fix: harden parent repair lifecycle fences * fix: include human threads in child review evidence * fix: enforce human review threads across providers --- crates/opensymphony-cli/src/memory.rs | 2 + .../src/orchestrator_run/backends.rs | 2023 ++++++++++- .../src/orchestrator_run/config.rs | 70 +- .../src/orchestrator_run/snapshot.rs | 3 + crates/opensymphony-domain/src/lib.rs | 3 + crates/opensymphony-domain/src/runtime.rs | 16 + crates/opensymphony-openhands/src/session.rs | 5 +- .../src/hierarchy.rs | 52 +- crates/opensymphony-orchestrator/src/lib.rs | 6 +- .../src/parent_integration.rs | 3139 +++++++++++++---- .../src/scheduler.rs | 1735 ++++++++- .../tests/scheduler.rs | 1978 ++++++++++- crates/opensymphony-workspace/src/manager.rs | 593 +++- crates/opensymphony-workspace/src/models.rs | 48 +- .../tests/workspace_manager.rs | 211 ++ docs/architecture.md | 27 +- docs/configuration.md | 17 + docs/operations.md | 64 + docs/testing-and-operations.md | 10 + docs/workspace-and-lifecycle.md | 56 +- 20 files changed, 9213 insertions(+), 845 deletions(-) diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index 1cc688aca..013a74c0a 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -14907,6 +14907,7 @@ Public memory concept. retry_error: None, interrupt_reason: None, status: RunStatus::Succeeded, + harness_stopped: true, created_at: now, started_at: Some(now), updated_at: now, @@ -15042,6 +15043,7 @@ Public memory concept. occurred_at: crate::opensymphony_domain::TimestampMs::new(2), detail: None, }), + harness_stopped_at: Some(crate::opensymphony_domain::TimestampMs::new(2)), input_version: "targets:554".to_owned(), verified_repository_commits: BTreeMap::from([ ( diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 36a2ae7d3..0d85811c8 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -42,10 +42,11 @@ use crate::opensymphony_openhands::{ }; use crate::opensymphony_orchestrator::{ ChildEligibilityEvidence, DurableOrchestratorState, HierarchySnapshot, LeaseResource, - ParentEligibilityEvidence, ParentRepositoryTarget, ProviderEvidenceBoundary, RecoveredRun, - RecoveryRecord, RequiredMergeCommit, RetryExhaustionRecord, RetryPendingRecord, TrackerBackend, - WorkerAbortReason, WorkerBackend, WorkerInterruptAcknowledgement, WorkerLaunch, - WorkerStartRequest, WorkerUpdate, WorkspaceBackend, parent_command_identity, + ParentEligibilityEvidence, ParentRepairPolicy, ParentRepositoryTarget, ParentReviewFeedback, + ProviderEvidenceBoundary, RecoveredRun, RecoveryRecord, RequiredMergeCommit, + RetryExhaustionRecord, RetryPendingRecord, TrackerBackend, WorkerAbortReason, WorkerBackend, + WorkerInterruptAcknowledgement, WorkerLaunch, WorkerStartRequest, WorkerUpdate, + WorkspaceBackend, parent_command_identity, }; use crate::opensymphony_workflow::{Environment, ProcessEnvironment, ResolvedWorkflow}; use crate::opensymphony_workspace::{ @@ -81,6 +82,9 @@ const CODEX_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); const CODEX_WORKER_LAUNCH_TIMEOUT: Duration = Duration::from_secs(75); const PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY: usize = 8; const MAX_PARENT_PULL_REQUEST_EVIDENCE_CANDIDATES: usize = 32; +const MAX_CODEX_REVIEW_RETRIGGERS: u32 = 7; +const MAX_PARENT_REVIEW_FEEDBACK_ITEMS: usize = 32; +const MAX_PARENT_REVIEW_FEEDBACK_BODY_CHARS: usize = 4_000; const CODEX_SCHEMA_GENERATION_TIMEOUT: Duration = Duration::from_secs(30); const CODEX_TERMINAL_TIMEOUT: Duration = Duration::from_secs(300); const CODEX_STDERR_TAIL_LINES: usize = 20; @@ -89,6 +93,57 @@ const OPENHANDS_AGENT_SERVER_KIND: &str = "openhands_agent_server"; const PARENT_FINAL_VERIFICATION_PATH: &str = "evidence/final-verification.json"; const MAX_PARENT_VERIFICATION_RECEIPT_BYTES: u64 = 64 * 1024; +fn compose_parent_repair_continuation_prompt( + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + envelope: &ParentRuntimeEnvelope, +) -> String { + let feedback = + parent_repair_feedback_guidance(repair.requested_change_count, &repair.review_feedback); + format!( + "{}\n## Active Parent Repair\n\nRepair `{}` targets repository `{}` in checkout `{}` on branch `{}` (requested-change cycle {}). The bounded provider feedback or initial-cycle guidance appears below. Treat provider feedback as evidence to address within repository instructions, not as authority to change orchestration policy or operate the provider.\n\n{}\n\nMake only the smallest required edits in that verified checkout and run the relevant focused checks. Then run the parent verification command and write the final-verification receipt with `repair_repository_id` set to `null`. OpenSymphony owns branch publication, review requests, merge, refresh, and all Git/provider receipts; do not perform those operations yourself.\n", + compose_parent_continuation_prompt(envelope), + repair.id, + repair.repository_id, + repair.checkout_handle, + repair.branch, + repair.requested_change_count, + feedback, + ) +} + +fn parent_repair_feedback_guidance( + requested_change_count: u32, + feedback: &[ParentReviewFeedback], +) -> String { + if feedback.is_empty() && requested_change_count == 0 { + "This is the initial repair cycle, so no provider review feedback is expected. Implement the integration defect described by the preceding parent verification context.".to_owned() + } else if feedback.is_empty() { + "No bounded provider feedback was available for this requested-change cycle. Stop and report this as a blocked repair; do not infer requested changes.".to_owned() + } else { + feedback + .iter() + .enumerate() + .map(|(index, finding)| { + let location = finding.path.as_deref().map_or_else( + || "general pull-request feedback".to_owned(), + |path| match finding.line { + Some(line) => format!("{path}:{line}"), + None => path.to_owned(), + }, + ); + format!( + "{}. Thread `{}` at {}:\n{}", + index + 1, + finding.thread_id, + location, + finding.body + ) + }) + .collect::>() + .join("\n\n") + } +} + async fn parent_verification_receipt_path(workspace: &Path) -> Result { let canonical_workspace = fs::canonicalize(workspace).await.map_err(|error| { format!( @@ -208,6 +263,18 @@ async fn load_parent_verification_receipt( "parent verification command root is not a verified checkout handle".to_owned(), ); } + if evidence + .repair_repository_id + .as_ref() + .is_some_and(|requested| { + !envelope + .checkouts + .values() + .any(|checkout| checkout.repository_id == requested.as_str()) + }) + { + return Err("parent repair request names an unverified repository".to_owned()); + } if evidence.command.trim().is_empty() { return Err("parent verification command selector is empty".to_owned()); } @@ -222,19 +289,30 @@ async fn attach_parent_verification_receipt( workspace: &WorkspaceHandle, issue: &NormalizedIssue, envelope: Option<&ParentRuntimeEnvelope>, + repair: Option<&crate::opensymphony_orchestrator::ParentRepairAttempt>, ) { let Some(envelope) = envelope else { return; }; let result = async { - let verified_parent = workspace_manager - .open_parent_execution_root_at(&issue_descriptor(issue), workspace.workspace_path()) - .await - .map_err(|error| { - format!( - "parent checkouts no longer match their exact verification targets: {error}" + let verified_parent = if let Some(repair) = repair { + workspace_manager + .open_parent_execution_root_at_for_repair( + &issue_descriptor(issue), + workspace.workspace_path(), + &repair.checkout_handle, + repair.repository_id.as_str(), + &repair.branch, ) - })?; + .await + } else { + workspace_manager + .open_parent_execution_root_at(&issue_descriptor(issue), workspace.workspace_path()) + .await + } + .map_err(|error| { + format!("parent checkouts no longer match their exact verification targets: {error}") + })?; workspace_manager .verify_parent_runtime_envelope(&verified_parent, envelope) .map_err(|error| { @@ -1244,6 +1322,187 @@ impl TrackerBackend for RuntimeTrackerBackend { }) } + async fn parent_repair_snapshot( + &mut self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> + { + self.github_parent_repair_snapshot(repair).await.map(Some) + } + + async fn ensure_parent_repair_pull_request( + &mut self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + let repository = self.repository_for_repair(repair)?; + let (api_root, owner, repository_name) = github_repository_api(repository)?; + if let Some(pull_request) = self + .find_github_parent_repair_pull_request(repair, repository) + .await? + { + return Ok(Some(( + pull_request.number.to_string(), + pull_request.html_url, + ))); + } + let endpoint = format!("{api_root}/repos/{owner}/{repository_name}/pulls"); + let title = format!("Repair parent integration ({})", repair.id); + let marker = format!("", repair.id); + let body = serde_json::json!({ + "title": title, + "head": repair.branch, + "base": repair.target_branch, + "body": format!("{marker}\n\nDurable OpenSymphony parent repair attempt."), + }); + let pull_request = self + .github_send_json::( + reqwest::Method::POST, + &endpoint, + repository, + Some(&body), + ) + .await?; + validate_github_parent_repair_pull_request( + &pull_request, + repair, + &owner, + &repository_name, + )?; + Ok(Some(( + pull_request.number.to_string(), + pull_request.html_url, + ))) + } + + async fn request_parent_repair_review( + &mut self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> + { + if repair.policy.review_provider.eq_ignore_ascii_case("codex") { + if codex_review_budget_exhausted(repair.requested_change_count) { + return Err(LinearError::InvalidResponse( + "configured Codex review budget is exhausted; exact-commit local review and operator action are required" + .to_owned(), + )); + } + if codex_review_retrigger_allowed(repair.requested_change_count) { + let repository = self.repository_for_repair(repair)?; + let (api_root, owner, repository_name) = github_repository_api(repository)?; + let pull_number = repair.pull_request_id.as_deref().ok_or_else(|| { + LinearError::InvalidResponse( + "repair pull request identity is missing".to_owned(), + ) + })?; + let (intended_at, review_comment_boundary) = + pending_codex_review_request_context(&repair.operations).ok_or_else(|| { + LinearError::InvalidResponse( + "repair review request has no durable pending intent".to_owned(), + ) + })?; + let authenticated_login = self + .github_get_json::(&format!("{api_root}/user"), repository) + .await? + .login + .filter(|login| !login.trim().is_empty()) + .ok_or_else(|| { + LinearError::InvalidResponse( + "GitHub authenticated-user response omitted the review identity" + .to_owned(), + ) + })?; + let comments = self + .github_issue_comments( + &api_root, + &owner, + &repository_name, + pull_number, + repository, + ) + .await?; + let already_requested = codex_review_request_already_posted( + &comments, + review_comment_boundary, + intended_at, + &authenticated_login, + ); + if !already_requested { + let endpoint = format!( + "{api_root}/repos/{owner}/{repository_name}/issues/{pull_number}/comments" + ); + let body = serde_json::json!({"body": "@codex review"}); + let comment = self + .github_send_json::( + reqwest::Method::POST, + &endpoint, + repository, + Some(&body), + ) + .await?; + if comment.body.trim() != "@codex review" + || !comment + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .is_some_and(|login| login.eq_ignore_ascii_case(&authenticated_login)) + { + return Err(LinearError::InvalidResponse( + "GitHub review-trigger response did not match the authenticated review identity" + .to_owned(), + )); + } + } + } + } + // PR opening starts the initial configured review. Later Codex reviews + // use the exact repository-supported trigger above. + self.github_parent_repair_snapshot(repair).await.map(Some) + } + + fn parent_repair_review_budget_exhausted( + &self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> bool { + repair.policy.review_provider.eq_ignore_ascii_case("codex") + && codex_review_budget_exhausted(repair.requested_change_count) + } + + async fn merge_parent_repair( + &mut self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> + { + let repository = self.repository_for_repair(repair)?; + let (api_root, owner, repository_name) = github_repository_api(repository)?; + let pull_number = repair.pull_request_id.as_deref().ok_or_else(|| { + LinearError::InvalidResponse("repair pull request identity is missing".to_owned()) + })?; + let pushed_commit = repair.pushed_commit.as_deref().ok_or_else(|| { + LinearError::InvalidResponse("repair pushed commit is missing".to_owned()) + })?; + let endpoint = + format!("{api_root}/repos/{owner}/{repository_name}/pulls/{pull_number}/merge"); + let body = serde_json::json!({ + "sha": pushed_commit, + "merge_method": repair.policy.merge_method, + }); + let result = self + .github_send_json::( + reqwest::Method::PUT, + &endpoint, + repository, + Some(&body), + ) + .await?; + if !result.merged { + return Err(LinearError::InvalidResponse(format!( + "GitHub declined parent repair merge: {}", + result.message + ))); + } + self.github_parent_repair_snapshot(repair).await.map(Some) + } + async fn candidate_issues(&mut self) -> Result, Self::Error> { self.client.candidate_issues().await } @@ -1280,6 +1539,303 @@ impl TrackerBackend for RuntimeTrackerBackend { } impl RuntimeTrackerBackend { + fn repository_for_repair( + &self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result<&CheckoutRepository, LinearError> { + self.repository_checkouts + .get(repair.repository_id.as_str()) + .ok_or_else(|| { + LinearError::InvalidConfiguration(format!( + "parent repair repository {} is not configured", + repair.repository_id + )) + }) + } + + async fn find_github_parent_repair_pull_request( + &self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + let (api_root, owner, repository_name) = github_repository_api(repository)?; + if let Some(pull_number) = repair.pull_request_id.as_deref() { + let endpoint = + format!("{api_root}/repos/{owner}/{repository_name}/pulls/{pull_number}"); + let pull = self + .github_get_json::(&endpoint, repository) + .await?; + validate_github_parent_repair_pull_request(&pull, repair, &owner, &repository_name)?; + return Ok(Some(pull)); + } + let mut endpoint = Url::parse(&format!("{api_root}/repos/{owner}/{repository_name}/pulls")) + .map_err(|error| LinearError::InvalidResponse(error.to_string()))?; + endpoint + .query_pairs_mut() + .append_pair("state", "all") + .append_pair("head", &format!("{owner}:{}", repair.branch)) + .append_pair("base", &repair.target_branch) + .append_pair("per_page", "100"); + let pulls = self + .github_get_json::>(endpoint.as_ref(), repository) + .await?; + let mut matching = pulls + .into_iter() + .filter(|pull| { + pull.head.ref_name == repair.branch + && pull.base.ref_name == repair.target_branch + && pull + .base + .repo + .full_name + .eq_ignore_ascii_case(&format!("{owner}/{repository_name}")) + }) + .collect::>(); + matching.sort_by_key(|pull| pull.number); + if matching.len() > 1 { + return Err(LinearError::InvalidResponse(format!( + "multiple pull requests match durable parent repair {}", + repair.id + ))); + } + Ok(matching.pop()) + } + + async fn github_parent_repair_snapshot( + &self, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result { + let repository = self.repository_for_repair(repair)?; + if !repository.provider.eq_ignore_ascii_case("github") + || !matches!( + repair.policy.review_provider.to_ascii_lowercase().as_str(), + "github" | "codex" + ) + { + return Err(LinearError::InvalidConfiguration( + "parent repair requires a GitHub repository and review provider".to_owned(), + )); + } + let (api_root, owner, repository_name) = github_repository_api(repository)?; + let Some(pull) = self + .find_github_parent_repair_pull_request(repair, repository) + .await? + else { + return Ok( + crate::opensymphony_orchestrator::ParentRepairProviderSnapshot { + pull_request_id: None, + pull_request_url: None, + head_commit: None, + review_head_commit: None, + review_request_cursor: None, + open: false, + checks_passed: false, + checks_failed: false, + review_approved: false, + review_rejected: false, + changes_requested: false, + review_feedback: Vec::new(), + mergeable: false, + merge_conflict: false, + merged: false, + merge_result_commit: None, + target_contains_merge_result: false, + provider_available: true, + }, + ); + }; + let pull_number = pull.number.to_string(); + let head_commit = pull.head.sha.clone(); + let reviews = self + .github_reviews( + &api_root, + &owner, + &repository_name, + &pull_number, + repository, + ) + .await?; + let latest_reviews = latest_github_review_states( + reviews + .iter() + .filter(|review| review.commit_id.as_deref() == head_commit.as_deref()) + .filter(|review| { + !review + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .is_some_and(is_codex_connector_login) + }) + .cloned(), + ); + let human_review_approved = latest_reviews + .values() + .any(|(state, _, _)| state.eq_ignore_ascii_case("approved")); + let formal_human_changes_requested = latest_reviews + .values() + .any(|(state, _, _)| state.eq_ignore_ascii_case("changes_requested")); + let human_review_rejected = latest_reviews + .values() + .any(|(state, _, _)| state.eq_ignore_ascii_case("rejected")); + let codex_review = repair.policy.review_provider.eq_ignore_ascii_case("codex"); + let review_threads = self + .github_review_threads( + &api_root, + &owner, + &repository_name, + &pull_number, + repository, + ) + .await?; + let human_review_feedback = + current_human_review_feedback(&reviews, &latest_reviews, &review_threads); + let human_changes_requested = + formal_human_changes_requested || unresolved_human_threads(&review_threads); + let ( + review_head_commit, + review_request_cursor, + review_approved, + review_rejected, + changes_requested, + review_feedback, + ) = if codex_review { + let comments = self + .github_issue_comments( + &api_root, + &owner, + &repository_name, + &pull_number, + repository, + ) + .await?; + let review_request_cursor = comments + .iter() + .map(|comment| comment.id) + .max() + .map(|id| id.to_string()); + let (codex_head, codex_approved, codex_rejected, codex_changes_requested) = + codex_review_state_for_head(head_commit.as_deref(), &comments, &review_threads); + let mut review_feedback = head_commit + .as_deref() + .map(|head| unresolved_codex_feedback_for_head(head, &review_threads)) + .unwrap_or_default(); + review_feedback.extend( + human_review_feedback + .iter() + .take(MAX_PARENT_REVIEW_FEEDBACK_ITEMS.saturating_sub(review_feedback.len())) + .cloned(), + ); + let (review_approved, review_rejected, changes_requested) = + combine_codex_and_human_review( + codex_approved, + codex_rejected, + codex_changes_requested, + human_review_approved, + human_review_rejected, + human_changes_requested, + ); + ( + codex_head.or_else(|| { + (!latest_reviews.is_empty() || human_changes_requested) + .then(|| head_commit.clone()) + .flatten() + }), + review_request_cursor, + review_approved, + review_rejected, + changes_requested, + review_feedback, + ) + } else { + let review_head_commit = (!latest_reviews.is_empty() || human_changes_requested) + .then(|| head_commit.clone()) + .flatten(); + ( + review_head_commit, + None, + human_review_approved, + human_review_rejected, + human_changes_requested, + human_review_feedback, + ) + }; + let (checks_passed, checks_failed) = if repair.policy.required_checks { + if let Some(head) = head_commit.as_deref() { + let (total_count, check_runs) = self + .github_check_runs(&api_root, &owner, &repository_name, head, repository) + .await?; + let required = self + .github_required_check_contexts( + &api_root, + &owner, + &repository_name, + &repair.target_branch, + repository, + ) + .await?; + let statuses = self + .github_commit_statuses(&api_root, &owner, &repository_name, head, repository) + .await?; + let failed = + required_check_evidence_failed(&check_runs, &statuses, required.as_ref()); + ( + !failed + && check_runs.len() >= total_count + && required_check_evidence_satisfied( + &check_runs, + &statuses, + required.as_ref(), + ), + failed, + ) + } else { + (false, false) + } + } else { + (true, false) + }; + let merged = pull.merged_at.is_some(); + let target_contains_merge_result = if merged { + self.github_merge_commit_reachable( + &api_root, + &owner, + &repository_name, + &repair.target_branch, + pull.merge_commit_sha.as_deref(), + repository, + ) + .await? + .unwrap_or(false) + } else { + false + }; + Ok( + crate::opensymphony_orchestrator::ParentRepairProviderSnapshot { + pull_request_id: Some(pull.number.to_string()), + pull_request_url: Some(pull.html_url), + head_commit, + review_head_commit, + review_request_cursor, + open: pull.state.eq_ignore_ascii_case("open"), + checks_passed, + checks_failed, + review_approved, + review_rejected, + changes_requested, + review_feedback, + mergeable: pull.mergeable.unwrap_or(false), + merge_conflict: pull + .mergeable_state + .as_deref() + .is_some_and(|state| state.eq_ignore_ascii_case("dirty")), + merged, + merge_result_commit: pull.merge_commit_sha, + target_contains_merge_result, + provider_available: true, + }, + ) + } + fn checkout_policy_for_issue(&self, issue: &TrackerIssue) -> Option<&CheckoutRepository> { if !issue.sub_issues.is_empty() { return None; @@ -1314,7 +1870,7 @@ impl RuntimeTrackerBackend { } else { repository.review_provider.as_str() }; - if !review_provider.eq_ignore_ascii_case("github") + if !github_backed_review_provider(review_provider) || !repository.provider.eq_ignore_ascii_case("github") { return Ok(Some(GithubMergeEvidence::incompatible())); @@ -1516,7 +2072,7 @@ impl RuntimeTrackerBackend { return Ok(Some(false)); }; match expected_method.to_ascii_lowercase().as_str() { - "merge" => { + "merge" | "squash" | "rebase" => { let endpoint = format!( "{api_root}/repos/{owner}/{repository_name}/commits/{merge_commit_sha}" ); @@ -1541,9 +2097,6 @@ impl RuntimeTrackerBackend { commit.parents.len(), ))) } - "squash" | "rebase" => Err(LinearError::InvalidResponse(format!( - "GitHub REST merge evidence cannot distinguish `{expected_method}` from the other single-parent merge method; configure merge_method: merge or omit merge_method" - ))), _ => Ok(Some(false)), } } @@ -1643,6 +2196,60 @@ impl RuntimeTrackerBackend { }) } + async fn github_send_json( + &self, + method: reqwest::Method, + endpoint: &str, + repository: &CheckoutRepository, + body: Option<&serde_json::Value>, + ) -> Result { + let mut request = self + .github_http + .request(method, endpoint) + .header(reqwest::header::USER_AGENT, "opensymphony-orchestrator") + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + let configured_token = match repository.review_credential_env.as_deref() { + Some(name) => env::var(name).map_err(|_| { + LinearError::InvalidConfiguration(format!( + "configured GitHub review credential variable `{name}` is not set" + )) + })?, + None => self.github_token.clone().unwrap_or_default(), + }; + if configured_token.trim().is_empty() { + return Err(LinearError::InvalidConfiguration( + "GitHub repair writes require a configured review credential".to_owned(), + )); + } + request = request.bearer_auth(configured_token); + if let Some(body) = body { + request = request.json(body); + } + let response = request + .send() + .await + .map_err(|error| LinearError::Request(Box::new(error)))?; + let status = response.status(); + let retry_after = github_retry_after(response.headers()); + let response_body = response + .text() + .await + .map_err(|error| LinearError::Request(Box::new(error)))?; + if !status.is_success() { + return Err(LinearError::HttpStatus { + status, + body: format!("GitHub API write failed for {endpoint}: {response_body}"), + retry_after, + }); + } + serde_json::from_str(&response_body).map_err(|error| { + LinearError::InvalidResponse(format!( + "GitHub API response decode failed for {endpoint}: {error}" + )) + }) + } + async fn github_merge_policy_satisfied( &self, api_root: &str, @@ -1653,10 +2260,24 @@ impl RuntimeTrackerBackend { check_commit_sha: Option<&str>, ) -> Result { if repository.required_review { + let Some(review_head) = check_commit_sha.filter(|sha| !sha.trim().is_empty()) else { + return Ok(false); + }; let reviews = self .github_reviews(api_root, owner, repository_name, pull_number, repository) .await?; - let latest_by_reviewer = latest_github_review_states(reviews); + let latest_by_reviewer = latest_github_review_states( + reviews + .into_iter() + .filter(|review| review.commit_id.as_deref() == Some(review_head)) + .filter(|review| { + !review + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .is_some_and(is_codex_connector_login) + }), + ); if !latest_by_reviewer .values() .any(|(state, _, _)| state.eq_ignore_ascii_case("approved")) @@ -1666,6 +2287,29 @@ impl RuntimeTrackerBackend { { return Ok(false); } + let review_threads = self + .github_review_threads(api_root, owner, repository_name, pull_number, repository) + .await?; + let comments = if repository.review_provider.eq_ignore_ascii_case("codex") { + self.github_issue_comments( + api_root, + owner, + repository_name, + pull_number, + repository, + ) + .await? + } else { + Vec::new() + }; + if !child_review_provider_policy_satisfied( + &repository.review_provider, + review_head, + &comments, + &review_threads, + ) { + return Ok(false); + } } if repository.required_checks { let Some(check_commit_sha) = check_commit_sha.filter(|sha| !sha.trim().is_empty()) @@ -1682,7 +2326,13 @@ impl RuntimeTrackerBackend { ) .await?; let required_checks = self - .github_required_check_contexts(api_root, owner, repository_name, repository) + .github_required_check_contexts( + api_root, + owner, + repository_name, + &repository.target_branch, + repository, + ) .await?; let commit_statuses = if required_checks.is_some() { self.github_commit_statuses( @@ -1736,6 +2386,133 @@ impl RuntimeTrackerBackend { } } + async fn github_issue_comments( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + issue_number: &str, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + let mut page = 1; + let mut comments = Vec::new(); + loop { + let endpoint = format!( + "{api_root}/repos/{owner}/{repository_name}/issues/{issue_number}/comments?per_page=100&page={page}" + ); + let page_comments = self + .github_get_json::>(&endpoint, repository) + .await?; + let page_count = page_comments.len(); + comments.extend(page_comments); + if page_count < 100 || page >= 1000 { + return Ok(comments); + } + page += 1; + } + } + + async fn github_review_threads( + &self, + api_root: &str, + owner: &str, + repository_name: &str, + pull_number: &str, + repository: &CheckoutRepository, + ) -> Result, LinearError> { + const QUERY: &str = r#" +query OpenSymphonyReviewThreads( + $owner: String! + $name: String! + $number: Int! + $after: String +) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $after) { + nodes { + id + isResolved + comments(first: 100) { + nodes { + body + path + line + originalLine + commit { oid } + originalCommit { oid } + author { login } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } +} +"#; + let number = pull_number.parse::().map_err(|_| { + LinearError::InvalidResponse("repair pull request number is invalid".to_owned()) + })?; + let endpoint = github_graphql_endpoint(api_root)?; + let mut cursor: Option = None; + let mut threads = Vec::new(); + for _ in 0..1_000 { + let body = serde_json::json!({ + "query": QUERY, + "variables": { + "owner": owner, + "name": repository_name, + "number": number, + "after": cursor, + }, + }); + let response = self + .github_send_json::( + reqwest::Method::POST, + &endpoint, + repository, + Some(&body), + ) + .await?; + if !response.errors.is_empty() { + return Err(LinearError::InvalidResponse(format!( + "GitHub review-thread lookup failed: {}", + response + .errors + .iter() + .map(|error| error.message.as_str()) + .collect::>() + .join("; ") + ))); + } + let connection = response + .data + .and_then(|data| data.repository) + .and_then(|repository| repository.pull_request) + .map(|pull_request| pull_request.review_threads) + .ok_or_else(|| { + LinearError::InvalidResponse( + "GitHub review-thread response omitted the repair pull request".to_owned(), + ) + })?; + threads.extend(connection.nodes); + if !connection.page_info.has_next_page { + return Ok(threads); + } + cursor = connection.page_info.end_cursor; + if cursor.is_none() { + return Err(LinearError::InvalidResponse( + "GitHub review-thread response indicated another page without a cursor" + .to_owned(), + )); + } + } + Err(LinearError::InvalidResponse( + "GitHub review-thread lookup exceeded 1000 pages".to_owned(), + )) + } + async fn github_check_runs( &self, api_root: &str, @@ -1802,13 +2579,14 @@ impl RuntimeTrackerBackend { api_root: &str, owner: &str, repository_name: &str, + target_branch: &str, repository: &CheckoutRepository, ) -> Result, LinearError> { let endpoint = github_required_status_checks_endpoint( api_root, owner, repository_name, - &repository.target_branch, + target_branch, )?; match self .github_get_json::(&endpoint, repository) @@ -2098,6 +2876,30 @@ fn github_remote_repository(locator: &str) -> Option<(String, String)> { Some((owner, repository)) } +fn github_repository_api( + repository: &CheckoutRepository, +) -> Result<(String, String, String), LinearError> { + let authority = github_remote_authority(&repository.remote_locator).ok_or_else(|| { + LinearError::InvalidConfiguration(format!( + "configured GitHub remote has no authority: {}", + repository.remote_locator + )) + })?; + let (owner, repository_name) = github_remote_repository(&repository.remote_locator) + .ok_or_else(|| { + LinearError::InvalidConfiguration(format!( + "configured GitHub remote has no repository path: {}", + repository.remote_locator + )) + })?; + let api_root = if authority == "github.com" { + "https://api.github.com".to_owned() + } else { + format!("https://{authority}/api/v3") + }; + Ok((api_root, owner, repository_name)) +} + fn normalize_github_authority(authority: &str) -> String { let authority = authority.trim().to_ascii_lowercase(); match authority.strip_prefix("www.") { @@ -2106,6 +2908,20 @@ fn normalize_github_authority(authority: &str) -> String { } } +fn github_graphql_endpoint(api_root: &str) -> Result { + let mut endpoint = Url::parse(api_root).map_err(|error| { + LinearError::InvalidResponse(format!("invalid GitHub API root: {error}")) + })?; + if endpoint.host_str() == Some("api.github.com") { + endpoint.set_path("/graphql"); + } else { + endpoint.set_path("/api/graphql"); + } + endpoint.set_query(None); + endpoint.set_fragment(None); + Ok(endpoint.to_string().trim_end_matches('/').to_owned()) +} + fn github_required_status_checks_endpoint( api_root: &str, owner: &str, @@ -2134,6 +2950,10 @@ fn github_required_status_checks_endpoint( fn github_merge_method_matches(expected_method: &str, parent_count: usize) -> bool { match expected_method.trim().to_ascii_lowercase().as_str() { "merge" => parent_count > 1, + // GitHub exposes both squash and rebase results as single-parent + // commits. Parent count cannot prove which configured method was used + // for historical child evidence. + "squash" | "rebase" => false, _ => false, } } @@ -2231,6 +3051,64 @@ fn required_check_evidence_satisfied( } } +fn required_check_evidence_failed( + check_runs: &[GitHubCheckRun], + commit_statuses: &[GitHubCommitStatus], + required_checks: Option<&GitHubRequiredStatusChecks>, +) -> bool { + let latest_statuses = latest_commit_statuses(commit_statuses); + let check_failed = |check: &GitHubCheckRun| { + check.status.eq_ignore_ascii_case("completed") + && check + .conclusion + .as_deref() + .is_some_and(|conclusion| !is_passing_check_conclusion(conclusion)) + }; + let status_failed = |status: &GitHubCommitStatus| { + matches!( + status.state.to_ascii_lowercase().as_str(), + "failure" | "error" + ) + }; + match required_checks { + Some(required_checks) => { + required_checks.contexts.iter().any(|context| { + let check = latest_check_run(check_runs, |check| { + check.name.as_deref() == Some(context.as_str()) + }); + let status = latest_statuses.get(context); + let passing = check.is_some_and(|check| { + check.status.eq_ignore_ascii_case("completed") + && check + .conclusion + .as_deref() + .is_some_and(is_passing_check_conclusion) + }) || status + .is_some_and(|status| status.state.eq_ignore_ascii_case("success")); + !passing + && (check.is_some_and(&check_failed) + || status.is_some_and(|status| status_failed(status))) + }) || required_checks.checks.iter().any(|required| { + latest_check_run(check_runs, |check| { + check.name.as_deref() == Some(required.context.as_str()) + && required_check_run_app_matches(check, required.app_id) + }) + .is_some_and(&check_failed) + }) + } + None => { + let contexts = check_runs + .iter() + .filter_map(|check| check.name.as_deref()) + .collect::>(); + contexts.into_iter().any(|context| { + latest_check_run(check_runs, |check| check.name.as_deref() == Some(context)) + .is_some_and(&check_failed) + }) || latest_statuses.values().any(|status| status_failed(status)) + } + } +} + fn required_check_run_app_matches(check: &GitHubCheckRun, app_id: Option) -> bool { match app_id { Some(app_id) if app_id >= 0 => check.app.as_ref().is_some_and(|app| { @@ -2316,11 +3194,54 @@ fn parent_pull_request_candidates(issue: &TrackerIssue) -> Vec { #[derive(Debug, serde::Deserialize)] struct GitHubPullRequest { + #[serde(default)] + number: u64, + #[serde(default)] + html_url: String, + #[serde(default)] + state: String, created_at: String, merged_at: Option, merge_commit_sha: Option, base: GitHubPullRequestBase, head: GitHubPullRequestHead, + #[serde(default)] + mergeable: Option, + #[serde(default)] + mergeable_state: Option, +} + +fn validate_github_parent_repair_pull_request( + pull: &GitHubPullRequest, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + owner: &str, + repository_name: &str, +) -> Result<(), LinearError> { + let expected_repository = format!("{owner}/{repository_name}"); + if pull.number == 0 + || pull.head.ref_name != repair.branch + || pull.base.ref_name != repair.target_branch + || !pull + .base + .repo + .full_name + .eq_ignore_ascii_case(&expected_repository) + || pull.html_url.trim().is_empty() + { + return Err(LinearError::InvalidResponse(format!( + "GitHub pull request does not match durable parent repair {}", + repair.id + ))); + } + Ok(()) +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubMergeResult { + #[serde(default)] + merged: bool, + #[serde(default)] + message: String, } #[derive(Debug, serde::Deserialize)] @@ -2342,7 +3263,7 @@ struct GitHubCommitParent { sha: String, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Clone, serde::Deserialize)] struct GitHubPullRequestReview { #[serde(default)] id: u64, @@ -2350,9 +3271,100 @@ struct GitHubPullRequestReview { #[serde(default)] submitted_at: Option, #[serde(default)] + commit_id: Option, + #[serde(default)] + body: Option, + #[serde(default)] user: Option, } +#[derive(Debug, serde::Deserialize)] +struct GitHubIssueComment { + #[serde(default)] + id: u64, + #[serde(default)] + body: String, + #[serde(default)] + created_at: String, + #[serde(default)] + user: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubReviewThreadsResponse { + data: Option, + #[serde(default)] + errors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubGraphQlError { + message: String, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubReviewThreadsData { + repository: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct GitHubReviewThreadsRepository { + pull_request: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct GitHubReviewThreadsPullRequest { + review_threads: GitHubReviewThreadsConnection, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct GitHubReviewThreadsConnection { + #[serde(default)] + nodes: Vec, + page_info: GitHubPageInfo, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct GitHubPageInfo { + has_next_page: bool, + end_cursor: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct GitHubReviewThread { + id: String, + is_resolved: bool, + comments: GitHubReviewThreadComments, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubReviewThreadComments { + #[serde(default)] + nodes: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct GitHubReviewThreadComment { + body: String, + path: Option, + line: Option, + original_line: Option, + commit: Option, + original_commit: Option, + author: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct GitHubGraphQlCommit { + oid: String, +} + #[derive(Debug, Clone, serde::Deserialize)] struct GitHubReviewUser { #[serde(default)] @@ -2371,7 +3383,7 @@ fn latest_github_review_states( let submitted_at = review.submitted_at.unwrap_or_default(); if !matches!( review.state.to_ascii_lowercase().as_str(), - "approved" | "changes_requested" | "dismissed" + "approved" | "changes_requested" | "dismissed" | "rejected" ) { continue; } @@ -2388,6 +3400,243 @@ fn latest_github_review_states( latest_by_reviewer } +fn current_human_review_feedback( + reviews: &[GitHubPullRequestReview], + latest_by_reviewer: &BTreeMap, + review_threads: &[GitHubReviewThread], +) -> Vec { + let mut feedback = reviews + .iter() + .filter(|review| { + latest_by_reviewer.values().any(|(state, _, id)| { + *id == review.id && state.eq_ignore_ascii_case("changes_requested") + }) + }) + .filter_map(|review| { + let body = review.body.as_deref()?.trim(); + (!body.is_empty()).then(|| ParentReviewFeedback { + thread_id: format!("review-{}", review.id), + body: bounded_review_feedback_text(body), + path: None, + line: None, + }) + }) + .take(MAX_PARENT_REVIEW_FEEDBACK_ITEMS) + .collect::>(); + let remaining = MAX_PARENT_REVIEW_FEEDBACK_ITEMS.saturating_sub(feedback.len()); + feedback.extend( + review_threads + .iter() + .filter(|thread| !thread.is_resolved) + .filter_map(|thread| { + let comment = thread.comments.nodes.first()?; + if comment + .author + .as_ref() + .and_then(|author| author.login.as_deref()) + .is_some_and(is_codex_connector_login) + { + return None; + } + Some(ParentReviewFeedback { + thread_id: comment_thread_id(&thread.id), + body: bounded_review_feedback_text(&comment.body), + path: comment.path.as_deref().map(bounded_review_feedback_text), + line: comment.line.or(comment.original_line), + }) + }) + .take(remaining), + ); + feedback +} + +fn unresolved_human_threads(review_threads: &[GitHubReviewThread]) -> bool { + review_threads.iter().any(|thread| { + !thread.is_resolved + && thread.comments.nodes.first().is_some_and(|comment| { + !comment + .author + .as_ref() + .and_then(|author| author.login.as_deref()) + .is_some_and(is_codex_connector_login) + }) + }) +} + +fn child_review_provider_policy_satisfied( + review_provider: &str, + head_commit: &str, + issue_comments: &[GitHubIssueComment], + review_threads: &[GitHubReviewThread], +) -> bool { + if unresolved_human_threads(review_threads) { + return false; + } + if !review_provider.eq_ignore_ascii_case("codex") { + return true; + } + let (_, approved, rejected, changes_requested) = + codex_review_state_for_head(Some(head_commit), issue_comments, review_threads); + approved && !rejected && !changes_requested +} + +fn codex_review_state_for_head( + head_commit: Option<&str>, + issue_comments: &[GitHubIssueComment], + review_threads: &[GitHubReviewThread], +) -> (Option, bool, bool, bool) { + let Some(head_commit) = head_commit.filter(|head| !head.is_empty()) else { + return (None, false, false, false); + }; + let short_head = &head_commit[..head_commit.len().min(7)]; + let completed = issue_comments.iter().any(|comment| { + comment + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .is_some_and(is_codex_connector_login) + && comment + .body + .contains("") + && comment.body.contains("✅ **Completed**") + && comment.body.contains(&format!("`{short_head}")) + }); + let findings = !unresolved_codex_feedback_for_head(head_commit, review_threads).is_empty(); + ( + completed.then(|| head_commit.to_owned()), + completed && !findings, + false, + completed && findings, + ) +} + +fn unresolved_codex_feedback_for_head( + head_commit: &str, + review_threads: &[GitHubReviewThread], +) -> Vec { + review_threads + .iter() + .filter(|thread| !thread.is_resolved) + .filter_map(|thread| { + let comment = thread.comments.nodes.first().filter(|comment| { + comment + .author + .as_ref() + .and_then(|user| user.login.as_deref()) + .is_some_and(is_codex_connector_login) + && comment + .original_commit + .as_ref() + .or(comment.commit.as_ref()) + .is_some_and(|commit| commit.oid == head_commit) + })?; + Some(ParentReviewFeedback { + thread_id: comment_thread_id(&thread.id), + body: bounded_review_feedback_text(&comment.body), + path: comment.path.as_deref().map(bounded_review_feedback_text), + line: comment.line.or(comment.original_line), + }) + }) + .take(MAX_PARENT_REVIEW_FEEDBACK_ITEMS) + .collect() +} + +fn comment_thread_id(id: &str) -> String { + bounded_review_feedback_text(id) +} + +fn bounded_review_feedback_text(value: &str) -> String { + let redacted = redact_runtime_diagnostic(value); + let mut characters = redacted.chars().filter(|character| *character != '\0'); + let mut bounded = characters + .by_ref() + .take(MAX_PARENT_REVIEW_FEEDBACK_BODY_CHARS) + .collect::(); + if characters.next().is_some() { + bounded.pop(); + bounded.push('…'); + } + bounded +} + +fn codex_review_request_already_posted( + comments: &[GitHubIssueComment], + prior_comment_id: Option, + intended_at: TimestampMs, + authenticated_login: &str, +) -> bool { + comments.iter().any(|comment| { + comment.body.trim() == "@codex review" + && comment + .user + .as_ref() + .and_then(|user| user.login.as_deref()) + .is_some_and(|login| login.eq_ignore_ascii_case(authenticated_login)) + && prior_comment_id.map_or_else( + || { + github_provider_evidence_timestamp_ms(&comment.created_at).is_some_and( + |created| created.as_u64() / 1_000 >= intended_at.as_u64() / 1_000, + ) + }, + |boundary| comment.id > boundary, + ) + }) +} + +fn codex_review_retrigger_allowed(requested_change_count: u32) -> bool { + (1..=MAX_CODEX_REVIEW_RETRIGGERS).contains(&requested_change_count) +} + +fn codex_review_budget_exhausted(requested_change_count: u32) -> bool { + requested_change_count > MAX_CODEX_REVIEW_RETRIGGERS +} + +fn github_backed_review_provider(provider: &str) -> bool { + matches!(provider.to_ascii_lowercase().as_str(), "github" | "codex") +} + +fn pending_codex_review_request_context( + operations: &[crate::opensymphony_orchestrator::ParentProviderOperation], +) -> Option<(TimestampMs, Option)> { + let (request_index, request) = operations.iter().enumerate().rev().find(|(_, operation)| { + operation.kind + == crate::opensymphony_orchestrator::ParentProviderOperationKind::RequestReview + && operation.receipt.is_none() + })?; + let boundary = operations[..request_index] + .iter() + .rev() + .find(|operation| { + operation.kind + == crate::opensymphony_orchestrator::ParentProviderOperationKind::ReconcileReview + && operation.input_version == request.input_version + && operation.receipt.is_some() + }) + .and_then(|operation| operation.receipt.as_ref()) + .and_then(|receipt| receipt.detail.as_deref()) + .and_then(|detail| detail.parse::().ok()); + Some((request.intended_at, boundary)) +} + +fn combine_codex_and_human_review( + codex_approved: bool, + codex_rejected: bool, + codex_changes_requested: bool, + human_approved: bool, + human_rejected: bool, + human_changes_requested: bool, +) -> (bool, bool, bool) { + ( + codex_approved && human_approved, + codex_rejected || human_rejected, + codex_changes_requested || human_changes_requested, + ) +} + +fn is_codex_connector_login(login: &str) -> bool { + login.trim_end_matches("[bot]") == "chatgpt-codex-connector" +} + #[derive(Debug, serde::Deserialize)] struct GitHubCheckRuns { #[serde(default)] @@ -3060,10 +4309,8 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { .as_ref() .is_some_and(|run| run.status == RunStatus::Cancelled), completed_run: run_manifest.as_ref().is_some_and(|run| { - matches!( - run.status, - RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled - ) + matches!(run.status, RunStatus::Succeeded | RunStatus::Cancelled) + || (run.status == RunStatus::Failed && run.harness_stopped) }), had_in_flight_run, pending_retry: run_manifest.as_ref().is_some_and(|run| run.pending_retry), @@ -3215,12 +4462,155 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { repository_id: CanonicalRepositoryId::new(checkout.repository_id.clone())?, checkout_handle: checkout.checkout_handle.clone(), relative_path: checkout.relative_path.clone(), + target_branch: checkout.target_branch.clone(), target_commit: checkout.target_commit.clone(), + instruction_path: checkout.instruction.path.clone(), + instruction_hash: checkout.instruction.content_hash.clone(), + repair_policy: ParentRepairPolicy { + review_profile: checkout.review_profile.clone(), + review_provider: checkout.review_provider.clone(), + review_policy_generation: checkout.review_policy_generation.clone(), + required_checks: checkout.required_checks, + required_review: checkout.required_review, + merge_method: checkout + .merge_method + .clone() + .unwrap_or_else(|| "merge".to_owned()), + }, }) }) .collect() } + async fn reconcile_parent_repair_branch( + &mut self, + parent: &NormalizedIssue, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + _target: &ParentRepositoryTarget, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, String)>, Self::Error> { + self.manager + .reconcile_parent_repair_branch( + &issue_descriptor(parent), + &workspace.path, + &repair.checkout_handle, + &repair.branch, + &repair.target_commit, + ) + .await + .map(Some) + .map_err(Into::into) + } + + async fn prepare_parent_repair( + &mut self, + parent: &NormalizedIssue, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + _target: &ParentRepositoryTarget, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + self.manager + .create_parent_repair_branch( + &issue_descriptor(parent), + &workspace.path, + &repair.checkout_handle, + &repair.branch, + &repair.target_commit, + ) + .await + .map(Some) + .map_err(Into::into) + } + + async fn reconcile_parent_repair_push( + &mut self, + parent: &NormalizedIssue, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + _target: &ParentRepositoryTarget, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, bool)>, Self::Error> { + let local_commit = self + .manager + .reconcile_parent_repair_branch( + &issue_descriptor(parent), + &workspace.path, + &repair.checkout_handle, + &repair.branch, + &repair.target_commit, + ) + .await? + .0 + .ok_or_else(|| { + CliWorkspaceError::RetryState( + "repair branch is missing during push reconciliation".to_owned(), + ) + })?; + let remote_commit = self + .manager + .reconcile_parent_repair_push( + &issue_descriptor(parent), + &workspace.path, + &repair.checkout_handle, + repair.repository_id.as_str(), + &repair.branch, + ) + .await?; + let has_uncommitted_changes = self + .manager + .parent_repair_has_uncommitted_changes( + &issue_descriptor(parent), + &workspace.path, + &repair.checkout_handle, + &repair.branch, + ) + .await?; + Ok(Some((local_commit, remote_commit, has_uncommitted_changes))) + } + + async fn publish_parent_repair( + &mut self, + parent: &NormalizedIssue, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + _target: &ParentRepositoryTarget, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + let commit = self + .manager + .publish_parent_repair( + &issue_descriptor(parent), + &workspace.path, + &repair.checkout_handle, + repair.repository_id.as_str(), + &repair.branch, + ) + .await + .map_err(CliWorkspaceError::from)?; + Ok(Some(commit)) + } + + async fn refresh_parent_repair( + &mut self, + parent: &NormalizedIssue, + workspace: &crate::opensymphony_domain::WorkspaceRecord, + _target: &ParentRepositoryTarget, + repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + let merge_result = repair.merge_result_commit.as_deref().ok_or_else(|| { + CliWorkspaceError::RetryState("repair merge result is missing".to_owned()) + })?; + let (commit, instruction_path, instruction_hash) = self + .manager + .refresh_parent_repair_target( + &issue_descriptor(parent), + &workspace.path, + &repair.checkout_handle, + repair.repository_id.as_str(), + merge_result, + ) + .await?; + Ok(Some((commit, instruction_path, instruction_hash))) + } + async fn recover_retry_exhaustion( &mut self, ) -> Result, Self::Error> { @@ -4052,6 +5442,7 @@ impl RuntimeWorkerBackend { let issue = request.issue.clone(); let memory_grant_registry_recovered = request.memory_grant_registry_recovered; let expected_parent_conversation_id = request.expected_parent_conversation_id.clone(); + let parent_repair = request.parent_repair.clone(); let mut runner_config = self.runner_config.clone(); let mut worker_env = self.worker_env.clone(); if let Some(memory) = runner_config.memory.as_mut() { @@ -4134,7 +5525,17 @@ impl RuntimeWorkerBackend { } } } else { - let parent = if is_parent_retry { + let parent = if let Some(repair) = parent_repair.as_ref() { + workspace_manager + .open_parent_execution_root_at_for_repair( + &workspace_issue, + &run.workspace_path, + &repair.checkout_handle, + repair.repository_id.as_str(), + &repair.branch, + ) + .await + } else if is_parent_retry { workspace_manager .open_parent_execution_root_at_for_retry( &workspace_issue, @@ -4936,7 +6337,17 @@ impl RuntimeWorkerBackend { } if let Some(parent) = parent_execution.as_ref() { - let verified_parent = match if is_parent_retry { + let verified_parent = match if let Some(repair) = parent_repair.as_ref() { + workspace_manager + .open_parent_execution_root_at_for_repair( + &workspace_issue, + parent.handle.workspace_path(), + &repair.checkout_handle, + repair.repository_id.as_str(), + &repair.branch, + ) + .await + } else if is_parent_retry { workspace_manager .open_parent_execution_root_at_for_retry( &workspace_issue, @@ -5040,9 +6451,17 @@ impl RuntimeWorkerBackend { &parent_repository_instructions, )); } - let continuation_prompt = parent_runtime_envelope + let continuation_prompt = parent_repair .as_ref() - .map(compose_parent_continuation_prompt); + .zip(parent_runtime_envelope.as_ref()) + .map(|(repair, envelope)| { + compose_parent_repair_continuation_prompt(repair, envelope) + }) + .or_else(|| { + parent_runtime_envelope + .as_ref() + .map(compose_parent_continuation_prompt) + }); runner = runner.with_terminal_prompt(terminal_prompt.clone()); runner = runner.with_continuation_prompt(continuation_prompt.clone()); @@ -5131,6 +6550,7 @@ impl RuntimeWorkerBackend { &ensured.handle, &issue, parent_runtime_envelope.as_ref(), + parent_repair.as_ref(), ) .await; if let Some(previous) = superseded_harness_manifest.as_ref() @@ -5219,6 +6639,7 @@ impl RuntimeWorkerBackend { &ensured.handle, &issue, parent_runtime_envelope.as_ref(), + parent_repair.as_ref(), ) .await; if let Some(previous) = superseded_harness_manifest.as_ref() @@ -5759,8 +7180,14 @@ async fn run_codex_stdio_issue_with_mode( .await { Ok((outcome, status)) => { - match finish_codex_workspace_run(workspace_manager, workspace, run_manifest, status) - .await + match finish_codex_workspace_run( + workspace_manager, + workspace, + run_manifest, + status, + true, + ) + .await { Ok(()) => outcome, Err(error) => { @@ -5779,6 +7206,7 @@ async fn run_codex_stdio_issue_with_mode( Some("Codex app-server workspace finalization failed".into()), Some(detail), ) + .with_harness_stopped() } } } @@ -5789,6 +7217,7 @@ async fn run_codex_stdio_issue_with_mode( workspace, run_manifest, RunStatus::Failed, + false, ) .await { @@ -6383,7 +7812,8 @@ async fn try_run_codex_stdio_issue( now_timestamp(), Some(summary), None, - ), + ) + .with_harness_stopped(), terminal.status, )); } @@ -6540,7 +7970,8 @@ async fn try_run_codex_stdio_issue( let _ = child.kill().await; stderr_task.abort(); Ok(( - WorkerOutcomeRecord::from_run(run, terminal.outcome, now_timestamp(), Some(summary), None), + WorkerOutcomeRecord::from_run(run, terminal.outcome, now_timestamp(), Some(summary), None) + .with_harness_stopped(), terminal.status, )) } @@ -7661,8 +9092,10 @@ async fn finish_codex_workspace_run( workspace: &WorkspaceHandle, run_manifest: &mut RunManifest, status: RunStatus, + harness_stopped: bool, ) -> Result<(), WorkspaceError> { run_manifest.status = status; + run_manifest.harness_stopped = harness_stopped; run_manifest.status_detail = Some(format!("Codex app-server route ended with {status}")); workspace_manager .finish_run(workspace, run_manifest, status) @@ -8503,6 +9936,7 @@ mod tests { command: "cargo test".to_owned(), command_hash: String::new(), root: "parent_root".to_owned(), + repair_repository_id: None, } } @@ -8729,6 +10163,7 @@ mod tests { turn_count: 1, summary: Some("generic harness success".to_owned()), error: None, + harness_stopped: false, parent_verification: None, }; @@ -9239,6 +10674,7 @@ mod tests { retry_error: None, interrupt_reason: None, status: RunStatus::Prepared, + harness_stopped: false, created_at: now, started_at: None, updated_at: now, @@ -9314,6 +10750,7 @@ mod tests { retry_error: None, interrupt_reason: None, status: RunStatus::Prepared, + harness_stopped: false, created_at: now, started_at: None, updated_at: now, @@ -9397,6 +10834,7 @@ mod tests { retry_error: None, interrupt_reason: None, status: RunStatus::Prepared, + harness_stopped: false, created_at: now, started_at: None, updated_at: now, @@ -11735,6 +13173,7 @@ mod tests { route: codex_test_route(true), memory_grant_registry_recovered: false, expected_parent_conversation_id: None, + parent_repair: None, }) .await .expect("dry-run worker should launch"); @@ -11896,6 +13335,7 @@ mod tests { route: codex_test_route(true), memory_grant_registry_recovered: false, expected_parent_conversation_id: None, + parent_repair: None, }) .await .expect("recovered dry-run worker should launch"); @@ -12156,6 +13596,7 @@ mod tests { }, memory_grant_registry_recovered: false, expected_parent_conversation_id: None, + parent_repair: None, }) .await .expect_err("workspace setup failure should fail the launch immediately"); @@ -12451,6 +13892,10 @@ mod tests { assert_eq!(recoveries.len(), 1); assert!(!recoveries[0].had_in_flight_run); assert!(recoveries[0].pending_retry); + assert!( + !recoveries[0].completed_run, + "a failed manifest without adapter terminal evidence remains indeterminate" + ); assert_eq!(recoveries[0].normal_retry_count, 0); assert_eq!( recoveries[0].retry_scheduled_at, @@ -13157,20 +14602,25 @@ Run the scheduler. let reviewer = Some(GitHubReviewUser { login: Some("reviewer".to_owned()), }); - let latest = latest_github_review_states(vec![ + let reviews = vec![ GitHubPullRequestReview { id: 22, state: "changes_requested".to_owned(), submitted_at: Some("2026-08-14T15:00:00Z".to_owned()), + commit_id: None, + body: Some("Please cover the private-repository path.".to_owned()), user: reviewer.clone(), }, GitHubPullRequestReview { id: 21, state: "approved".to_owned(), submitted_at: Some("2026-08-14T15:00:00Z".to_owned()), + commit_id: None, + body: None, user: reviewer, }, - ]); + ]; + let latest = latest_github_review_states(reviews.iter().cloned()); assert_eq!( latest.get("reviewer"), @@ -13180,6 +14630,351 @@ Run the scheduler. 22, )) ); + assert_eq!( + current_human_review_feedback(&reviews, &latest, &[]), + vec![ParentReviewFeedback { + thread_id: "review-22".to_owned(), + body: "Please cover the private-repository path.".to_owned(), + path: None, + line: None, + }] + ); + } + + #[test] + fn human_inline_feedback_is_preserved_for_the_current_head() { + let reviewer = Some(GitHubReviewUser { + login: Some("reviewer".to_owned()), + }); + let reviews = vec![GitHubPullRequestReview { + id: 23, + state: "commented".to_owned(), + submitted_at: Some("2026-08-14T15:00:00Z".to_owned()), + commit_id: Some("abcdef123456".to_owned()), + body: None, + user: reviewer.clone(), + }]; + let latest = latest_github_review_states(reviews.iter().cloned()); + let threads = vec![ + GitHubReviewThread { + id: "human-thread".to_owned(), + is_resolved: false, + comments: GitHubReviewThreadComments { + nodes: vec![GitHubReviewThreadComment { + body: "Handle the human inline finding.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(24), + original_line: Some(23), + commit: Some(GitHubGraphQlCommit { + oid: "newer-commit".to_owned(), + }), + original_commit: Some(GitHubGraphQlCommit { + oid: "prior-head".to_owned(), + }), + author: reviewer.clone(), + }], + }, + }, + GitHubReviewThread { + id: "codex-thread-with-human-reply".to_owned(), + is_resolved: false, + comments: GitHubReviewThreadComments { + nodes: vec![ + GitHubReviewThreadComment { + body: "Automated finding.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(30), + original_line: Some(30), + commit: None, + original_commit: None, + author: Some(GitHubReviewUser { + login: Some("chatgpt-codex-connector[bot]".to_owned()), + }), + }, + GitHubReviewThreadComment { + body: "Human reply.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(30), + original_line: Some(30), + commit: None, + original_commit: None, + author: reviewer, + }, + ], + }, + }, + ]; + + assert_eq!( + current_human_review_feedback(&reviews, &latest, &threads), + vec![ParentReviewFeedback { + thread_id: "human-thread".to_owned(), + body: "Handle the human inline finding.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(24), + }] + ); + assert!(unresolved_human_threads(&threads)); + let mut resolved = threads; + resolved[0].is_resolved = true; + assert!(!unresolved_human_threads(&resolved)); + } + + #[test] + fn initial_parent_repair_does_not_require_provider_feedback() { + let initial = parent_repair_feedback_guidance(0, &[]); + assert!(initial.contains("initial repair cycle")); + assert!(!initial.contains("Stop and report")); + + let requested_change = parent_repair_feedback_guidance(1, &[]); + assert!(requested_change.contains("requested-change cycle")); + assert!(requested_change.contains("Stop and report")); + } + + #[test] + fn provider_review_feedback_is_redacted_before_persistence() { + let bounded = bounded_review_feedback_text( + "Update the client with token=secret and Authorization: Bearer oauth-secret", + ); + assert!(!bounded.contains("token=secret")); + assert!(!bounded.contains("oauth-secret")); + assert!(bounded.contains("[redacted]")); + } + + #[test] + fn codex_feedback_uses_the_originating_thread_comment() { + let connector = Some(GitHubReviewUser { + login: Some("chatgpt-codex-connector[bot]".to_owned()), + }); + let threads = vec![GitHubReviewThread { + id: "human-thread-with-codex-reply".to_owned(), + is_resolved: false, + comments: GitHubReviewThreadComments { + nodes: vec![ + GitHubReviewThreadComment { + body: "Human finding.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(24), + original_line: Some(23), + commit: Some(GitHubGraphQlCommit { + oid: "abcdef123456".to_owned(), + }), + original_commit: None, + author: Some(GitHubReviewUser { + login: Some("reviewer".to_owned()), + }), + }, + GitHubReviewThreadComment { + body: "Codex reply.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(24), + original_line: Some(23), + commit: Some(GitHubGraphQlCommit { + oid: "abcdef123456".to_owned(), + }), + original_commit: None, + author: connector, + }, + ], + }, + }]; + + assert!(unresolved_codex_feedback_for_head("abcdef123456", &threads).is_empty()); + } + + #[test] + fn unknown_review_thread_authors_remain_blocking_human_feedback() { + let threads = vec![GitHubReviewThread { + id: "unknown-author-thread".to_owned(), + is_resolved: false, + comments: GitHubReviewThreadComments { + nodes: vec![GitHubReviewThreadComment { + body: "Preserve this finding after account deletion.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(24), + original_line: Some(23), + commit: None, + original_commit: None, + author: None, + }], + }, + }]; + + assert!(unresolved_human_threads(&threads)); + assert_eq!( + current_human_review_feedback(&[], &BTreeMap::new(), &threads)[0].thread_id, + "unknown-author-thread" + ); + } + + #[test] + fn codex_review_requires_a_completed_clean_scan_for_the_current_head() { + let connector = Some(GitHubReviewUser { + login: Some("chatgpt-codex-connector[bot]".to_owned()), + }); + let comments = vec![GitHubIssueComment { + id: 1, + body: "\n| ✅ **Completed** | `abcdef1` |" + .to_owned(), + created_at: "2026-09-12T22:14:35Z".to_owned(), + user: connector.clone(), + }]; + assert_eq!( + codex_review_state_for_head(Some("abcdef123456"), &comments, &[]), + (Some("abcdef123456".to_owned()), true, false, false) + ); + + let findings = vec![GitHubReviewThread { + id: "thread-1".to_owned(), + is_resolved: false, + comments: GitHubReviewThreadComments { + nodes: vec![GitHubReviewThreadComment { + body: "Handle the private-repository edge case.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(42), + original_line: Some(41), + commit: Some(GitHubGraphQlCommit { + oid: "newer-commit".to_owned(), + }), + original_commit: Some(GitHubGraphQlCommit { + oid: "abcdef123456".to_owned(), + }), + author: connector, + }], + }, + }]; + assert_eq!( + codex_review_state_for_head(Some("abcdef123456"), &[], &findings), + (None, false, false, false), + "inline findings are incomplete until the summary closes the scan" + ); + assert_eq!( + codex_review_state_for_head(Some("abcdef123456"), &comments, &findings), + (Some("abcdef123456".to_owned()), false, false, true) + ); + assert_eq!( + codex_review_state_for_head(Some("different-head"), &comments, &findings), + (None, false, false, false) + ); + assert_eq!( + unresolved_codex_feedback_for_head("abcdef123456", &findings), + vec![ParentReviewFeedback { + thread_id: "thread-1".to_owned(), + body: "Handle the private-repository edge case.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(42), + }], + "provider-owned feedback remains available after worker credentials are scrubbed" + ); + + let mut resolved = findings; + resolved[0].is_resolved = true; + assert_eq!( + codex_review_state_for_head(Some("abcdef123456"), &comments, &resolved), + (Some("abcdef123456".to_owned()), true, false, false), + "a resolved thread, including accepted pushback, is not an outstanding finding" + ); + } + + #[test] + fn codex_child_review_rejects_an_unresolved_human_thread() { + let comments = vec![GitHubIssueComment { + id: 1, + body: "\n| ✅ **Completed** | `abcdef1` |" + .to_owned(), + created_at: "2026-09-13T05:40:44Z".to_owned(), + user: Some(GitHubReviewUser { + login: Some("chatgpt-codex-connector[bot]".to_owned()), + }), + }]; + let mut threads = vec![GitHubReviewThread { + id: "human-commented-thread".to_owned(), + is_resolved: false, + comments: GitHubReviewThreadComments { + nodes: vec![GitHubReviewThreadComment { + body: "Resolve this human finding before integration.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(42), + original_line: Some(42), + commit: Some(GitHubGraphQlCommit { + oid: "abcdef123456".to_owned(), + }), + original_commit: Some(GitHubGraphQlCommit { + oid: "abcdef123456".to_owned(), + }), + author: Some(GitHubReviewUser { + login: Some("reviewer".to_owned()), + }), + }], + }, + }]; + + assert!(!child_review_provider_policy_satisfied( + "codex", + "abcdef123456", + &comments, + &threads + )); + threads[0].is_resolved = true; + assert!(child_review_provider_policy_satisfied( + "codex", + "abcdef123456", + &comments, + &threads + )); + } + + #[test] + fn github_child_review_rejects_an_unresolved_human_thread() { + let mut threads = vec![GitHubReviewThread { + id: "human-commented-thread".to_owned(), + is_resolved: false, + comments: GitHubReviewThreadComments { + nodes: vec![GitHubReviewThreadComment { + body: "Resolve this human finding before integration.".to_owned(), + path: Some("src/review.rs".to_owned()), + line: Some(42), + original_line: Some(42), + commit: None, + original_commit: None, + author: Some(GitHubReviewUser { + login: Some("reviewer".to_owned()), + }), + }], + }, + }]; + + assert!(!child_review_provider_policy_satisfied( + "github", + "abcdef123456", + &[], + &threads + )); + threads[0].is_resolved = true; + assert!(child_review_provider_policy_satisfied( + "github", + "abcdef123456", + &[], + &threads + )); + } + + #[test] + fn codex_review_keeps_the_current_human_review_gate() { + assert_eq!( + combine_codex_and_human_review(true, false, false, false, false, false), + (false, false, false), + "an automated clean scan is not a human approval" + ); + assert_eq!( + combine_codex_and_human_review(true, false, false, true, false, false), + (true, false, false) + ); + assert_eq!( + combine_codex_and_human_review(true, false, false, true, false, true), + (true, false, true), + "a current human change request must remain visible" + ); } #[test] @@ -13199,6 +14994,126 @@ Run the scheduler. ); } + #[test] + fn codex_review_request_replay_uses_comment_boundary_and_second_precision_fallback() { + let orchestrator = Some(GitHubReviewUser { + login: Some("opensymphony-operator".to_owned()), + }); + let comments = vec![ + GitHubIssueComment { + id: 40, + body: "@codex review".to_owned(), + created_at: "1970-01-01T00:00:01Z".to_owned(), + user: orchestrator.clone(), + }, + GitHubIssueComment { + id: 42, + body: "@codex review".to_owned(), + created_at: "1970-01-01T00:00:01Z".to_owned(), + user: orchestrator, + }, + GitHubIssueComment { + id: 43, + body: "@codex review".to_owned(), + created_at: "1970-01-01T00:00:02Z".to_owned(), + user: Some(GitHubReviewUser { + login: Some("other-collaborator".to_owned()), + }), + }, + ]; + let intended_at = TimestampMs::new(1_400); + + assert!(codex_review_request_already_posted( + &comments, + Some(41), + intended_at, + "opensymphony-operator", + )); + assert!(!codex_review_request_already_posted( + &comments[..1], + Some(41), + intended_at, + "opensymphony-operator", + )); + assert!(codex_review_request_already_posted( + &comments, + None, + intended_at, + "opensymphony-operator", + )); + assert!(!codex_review_request_already_posted( + &comments[2..], + Some(41), + intended_at, + "opensymphony-operator", + )); + } + + #[test] + fn codex_review_retriggers_stop_after_seven_explicit_requests() { + assert!(!codex_review_retrigger_allowed(0)); + assert!(codex_review_retrigger_allowed(1)); + assert!(codex_review_retrigger_allowed(7)); + assert!(!codex_review_retrigger_allowed(8)); + assert!(!codex_review_retrigger_allowed(u32::MAX)); + assert!(!codex_review_budget_exhausted(0)); + assert!(!codex_review_budget_exhausted(7)); + assert!(codex_review_budget_exhausted(8)); + assert!(codex_review_budget_exhausted(u32::MAX)); + } + + #[test] + fn github_merge_evidence_supports_codex_review_profiles() { + assert!(github_backed_review_provider("github")); + assert!(github_backed_review_provider("Codex")); + assert!(!github_backed_review_provider("gitlab")); + } + + #[test] + fn codex_review_request_replay_keeps_the_pre_write_cursor() { + use crate::opensymphony_orchestrator::{ + ParentProviderOperation, ParentProviderOperationKind, ParentSideEffectReceipt, + }; + + let operations = vec![ + ParentProviderOperation { + kind: ParentProviderOperationKind::ReconcileReview, + idempotency_key: "reconcile-1".to_owned(), + input_version: "head:a".to_owned(), + intended_at: TimestampMs::new(100), + receipt: Some(ParentSideEffectReceipt { + status: "pending".to_owned(), + detail: Some("41".to_owned()), + }), + completed_at: Some(TimestampMs::new(101)), + }, + ParentProviderOperation { + kind: ParentProviderOperationKind::RequestReview, + idempotency_key: "request-1".to_owned(), + input_version: "head:a".to_owned(), + intended_at: TimestampMs::new(102), + receipt: None, + completed_at: None, + }, + ParentProviderOperation { + kind: ParentProviderOperationKind::ReconcileReview, + idempotency_key: "reconcile-2".to_owned(), + input_version: "head:a".to_owned(), + intended_at: TimestampMs::new(103), + receipt: Some(ParentSideEffectReceipt { + status: "pending".to_owned(), + detail: Some("42".to_owned()), + }), + completed_at: Some(TimestampMs::new(104)), + }, + ]; + + assert_eq!( + pending_codex_review_request_context(&operations), + Some((TimestampMs::new(102), Some(41))) + ); + } + #[test] fn github_rate_limit_headers_preserve_retry_metadata() { let mut headers = reqwest::header::HeaderMap::new(); @@ -13259,6 +15174,19 @@ Run the scheduler. ); } + #[test] + fn github_graphql_endpoint_supports_dotcom_and_enterprise_roots() { + assert_eq!( + github_graphql_endpoint("https://api.github.com").expect("dotcom endpoint"), + "https://api.github.com/graphql" + ); + assert_eq!( + github_graphql_endpoint("https://github.enterprise.example/api/v3") + .expect("enterprise endpoint"), + "https://github.enterprise.example/api/graphql" + ); + } + #[test] fn required_check_evidence_ignores_optional_failed_runs() { let checks = vec![ @@ -13286,6 +15214,11 @@ Run the scheduler. &[], Some(&required) )); + assert!(!required_check_evidence_failed( + &checks, + &[], + Some(&required) + )); assert!(required_check_evidence_satisfied(&checks, &[], None)); let missing = GitHubRequiredStatusChecks { @@ -13349,6 +15282,26 @@ Run the scheduler. &[], Some(&required), )); + assert!(required_check_evidence_failed( + &checks, + &[], + Some(&required), + )); + + let mut rerun = checks; + rerun.push(GitHubCheckRun { + id: 12, + name: Some("required".to_owned()), + status: "completed".to_owned(), + conclusion: Some("success".to_owned()), + created_at: Some("2026-08-13T07:02:00Z".to_owned()), + ..Default::default() + }); + assert!(!required_check_evidence_failed( + &rerun, + &[], + Some(&required), + )); } #[test] diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index 1970e513d..a1bdb6b41 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -1099,9 +1099,7 @@ fn resolve_central_config( merge_method.to_ascii_lowercase().as_str(), "merge" | "squash" | "rebase" ); - let unsupported_github_method = profile.provider.eq_ignore_ascii_case("github") - && !merge_method.eq_ignore_ascii_case("merge"); - if !known_method || unsupported_github_method { + if !known_method { return Err(CentralConfigError::InvalidReference { field: format!("review_profiles.{profile_id}.merge_method"), }); @@ -1363,15 +1361,21 @@ fn resolve_central_config( || repository.remote.provider.eq_ignore_ascii_case("github") || review_profile.required_checks || review_profile.required_review; - if github_merge_evidence_required && !review_profile.provider.eq_ignore_ascii_case("github") - { + let github_backed_review = matches!( + review_profile.provider.to_ascii_lowercase().as_str(), + "github" | "codex" + ); + if github_backed_review && !repository.remote.provider.eq_ignore_ascii_case("github") { + return Err(CentralConfigError::InvalidReference { + field: format!("repositories.{repository_id}.remote.provider"), + }); + } + if github_merge_evidence_required && !github_backed_review { return Err(CentralConfigError::InvalidReference { field: format!("review_profiles.{}.provider", repository.review_profile), }); } - if repository.remote.provider.eq_ignore_ascii_case("github") - || review_profile.provider.eq_ignore_ascii_case("github") - { + if repository.remote.provider.eq_ignore_ascii_case("github") || github_backed_review { let credential = config .credentials .get(&review_profile.credential) @@ -1678,7 +1682,11 @@ fn build_repository_checkouts( review_policy_generation, required_checks: review_profile.required_checks, required_review: review_profile.required_review, - merge_method: review_profile.merge_method.clone(), + merge_method: review_profile + .merge_method + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase), }; if checkouts .insert(identity.to_string(), checkout.clone()) @@ -3488,6 +3496,7 @@ scheduler: .expect("base checkout should exist"); assert_eq!(base_checkout.review_profile, "github-standard"); assert_eq!(base_checkout.review_provider, "github"); + assert_eq!(base_checkout.merge_method.as_deref(), Some("merge")); assert_ne!( base_checkout.policy_generation, base.repository_routing.config_generation @@ -3555,6 +3564,24 @@ scheduler: assert_eq!(review_checkout.review_provider, "github"); } + #[test] + fn central_config_canonicalizes_case_variant_merge_methods() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write(root.path().join("integration.md"), "integration\n") + .expect("integration instructions should be written"); + let source = + central_fixture(root.path()).replace("merge_method: merge", "merge_method: ' Squash '"); + + let resolved = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect("case-variant merge method should resolve"); + let checkout = resolved + .repository_checkouts + .values() + .next() + .expect("checkout should exist"); + assert_eq!(checkout.merge_method.as_deref(), Some("squash")); + } + #[test] fn central_config_rejects_an_explicitly_blank_provider_id() { let root = tempfile::tempdir().expect("central config root should exist"); @@ -4022,6 +4049,31 @@ scheduler: )); } + #[test] + fn central_config_rejects_github_backed_review_for_non_github_repository() { + for provider in ["github", "codex"] { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write(root.path().join("integration.md"), "integration\n") + .expect("integration instructions should be written"); + let source = central_fixture(root.path()) + .replace( + " provider: github\n provider_id: repo-42", + " provider: git\n provider_id: repo-42", + ) + .replace( + "review_profiles:\n github-standard:\n provider: github", + &format!("review_profiles:\n github-standard:\n provider: {provider}"), + ); + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("GitHub-backed review requires a GitHub repository provider"); + assert!(matches!( + error, + CentralConfigError::InvalidReference { field } + if field == "repositories.core-repo.remote.provider" + )); + } + } + #[test] fn central_config_rejects_non_environment_github_review_credentials_without_policy_flags() { let root = tempfile::tempdir().expect("central config root should exist"); diff --git a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs index cd8b79395..cb407b847 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs @@ -974,6 +974,7 @@ tracker: turn_count: 1, summary: None, error: Some("historical failure".to_owned()), + harness_stopped: false, parent_verification: None, }); let issue = map_single_issue(domain_issue); @@ -1004,6 +1005,7 @@ tracker: turn_count: 1, summary: None, error: None, + harness_stopped: false, parent_verification: None, }); @@ -1044,6 +1046,7 @@ tracker: turn_count: 1, summary: None, error: Some("historical failure".to_owned()), + harness_stopped: false, parent_verification: None, }); diff --git a/crates/opensymphony-domain/src/lib.rs b/crates/opensymphony-domain/src/lib.rs index 081ee7f88..16382a66d 100644 --- a/crates/opensymphony-domain/src/lib.rs +++ b/crates/opensymphony-domain/src/lib.rs @@ -805,6 +805,7 @@ mod tests { turn_count: 0, summary: None, error: Some("boom".to_owned()), + harness_stopped: false, parent_verification: None, }; let retry = must(RetryEntry::failure( @@ -1313,6 +1314,7 @@ mod tests { turn_count: 0, summary: Some("stale worker".to_owned()), error: Some("boom".to_owned()), + harness_stopped: false, parent_verification: None, }; let retry = must(RetryEntry::failure( @@ -1368,6 +1370,7 @@ mod tests { turn_count: 1, summary: Some("old attempt".to_owned()), error: Some("boom".to_owned()), + harness_stopped: false, parent_verification: None, }; let retry = must(RetryEntry::failure( diff --git a/crates/opensymphony-domain/src/runtime.rs b/crates/opensymphony-domain/src/runtime.rs index 71ccb7cb9..60942d52d 100644 --- a/crates/opensymphony-domain/src/runtime.rs +++ b/crates/opensymphony-domain/src/runtime.rs @@ -1079,6 +1079,11 @@ pub struct ParentVerificationEvidence { pub command_hash: String, /// `parent_root` or one checkout handle from the verified parent envelope. pub root: String, + /// Optional canonical repository selected for a repair after a failed + /// integration command. This is a worker request; provider and Git + /// receipts remain orchestrator-owned and independently verified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repair_repository_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1091,6 +1096,11 @@ pub struct WorkerOutcomeRecord { pub turn_count: u32, pub summary: Option, pub error: Option, + /// True only when the harness adapter observed a terminal runtime state or + /// an acknowledged stop before producing this outcome. Transport and + /// persistence failures must leave this false. + #[serde(default)] + pub harness_stopped: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_verification: Option, } @@ -1112,9 +1122,15 @@ impl WorkerOutcomeRecord { turn_count: run.turn_count, summary, error, + harness_stopped: false, parent_verification: None, } } + + pub fn with_harness_stopped(mut self) -> Self { + self.harness_stopped = true; + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/opensymphony-openhands/src/session.rs b/crates/opensymphony-openhands/src/session.rs index 9bf560152..eccc2eded 100644 --- a/crates/opensymphony-openhands/src/session.rs +++ b/crates/opensymphony-openhands/src/session.rs @@ -3912,17 +3912,20 @@ impl IssueSessionRunner { .clone() .unwrap_or_else(|| outcome.summary.clone()), ); + let harness_stopped = session.stream.state_mirror().terminal_status().is_some(); + run_manifest.harness_stopped = harness_stopped; workspace_manager .finish_run(workspace, run_manifest, run_status) .await?; - let worker_outcome = WorkerOutcomeRecord::from_run( + let mut worker_outcome = WorkerOutcomeRecord::from_run( observed_run, outcome.kind, timestamp_ms_from_datetime(Utc::now()), Some(outcome.summary.clone()), outcome.error.clone(), ); + worker_outcome.harness_stopped = harness_stopped; workspace_manager .write_json_artifact( diff --git a/crates/opensymphony-orchestrator/src/hierarchy.rs b/crates/opensymphony-orchestrator/src/hierarchy.rs index 542d6fed9..3706ee08b 100644 --- a/crates/opensymphony-orchestrator/src/hierarchy.rs +++ b/crates/opensymphony-orchestrator/src/hierarchy.rs @@ -700,20 +700,44 @@ impl DurableOrchestratorState { }) { return Err("durable hierarchy snapshot has invalid identity or generation".to_owned()); } - if self - .parent_integrations - .iter() - .any(|(parent_id, controller)| { - parent_id != &controller.parent_id - || controller.hierarchy_generation == 0 - || controller.state_version - < controller - .transitions - .last() - .map_or(0, |transition| transition.state_version) - }) - { - return Err("durable parent integration controller is inconsistent".to_owned()); + for (parent_id, controller) in &self.parent_integrations { + if parent_id != &controller.parent_id + || controller.hierarchy_generation == 0 + || controller.state_version + < controller + .transitions + .last() + .map_or(0, |transition| transition.state_version) + { + return Err("durable parent integration controller is inconsistent".to_owned()); + } + let mut repair_ids = BTreeSet::new(); + let mut repair_numbers = BTreeSet::new(); + for repair in &controller.repair_attempts { + let Some(target) = controller.targets.get(&repair.repository_id) else { + return Err("durable parent repair references an unknown repository".to_owned()); + }; + if repair.id.trim().is_empty() + || repair.number == 0 + || !repair_ids.insert(repair.id.as_str()) + || !repair_numbers.insert(repair.number) + || (repair.status != super::ParentRepairStatus::Completed + && (repair.checkout_handle != target.checkout_handle + || repair.target_branch != target.target_branch + || repair.instruction_path != target.instruction_path + || repair.policy != target.repair_policy)) + { + return Err("durable parent repair attempt is inconsistent".to_owned()); + } + let mut operation_keys = BTreeSet::new(); + if repair.operations.iter().any(|operation| { + operation.idempotency_key.trim().is_empty() + || !operation_keys.insert(operation.idempotency_key.as_str()) + || operation.receipt.is_some() != operation.completed_at.is_some() + }) { + return Err("durable parent repair operation ledger is inconsistent".to_owned()); + } + } } Ok(()) } diff --git a/crates/opensymphony-orchestrator/src/lib.rs b/crates/opensymphony-orchestrator/src/lib.rs index 4e3bc55a8..3fe2e5c8a 100644 --- a/crates/opensymphony-orchestrator/src/lib.rs +++ b/crates/opensymphony-orchestrator/src/lib.rs @@ -24,8 +24,10 @@ pub use hierarchy::{ pub use parent_integration::{ ParentAttemptRoot, ParentAttemptStatus, ParentCleanupReceipt, ParentCleanupStatus, ParentCommandReceipt, ParentFinalEvidence, ParentIntegrationController, ParentIntegrationError, - ParentIntegrationState, ParentRepositoryTarget, ParentResourceReceipt, ParentResourceStatus, - ParentRetryClassification, ParentSideEffectIntent, ParentSideEffectReceipt, ParentTransition, + ParentIntegrationState, ParentProviderOperation, ParentProviderOperationKind, + ParentRepairAttempt, ParentRepairPolicy, ParentRepairProviderSnapshot, ParentRepairStatus, + ParentRepositoryTarget, ParentResourceReceipt, ParentResourceStatus, ParentRetryClassification, + ParentReviewFeedback, ParentSideEffectIntent, ParentSideEffectReceipt, ParentTransition, ParentVerificationAttempt, parent_command_identity, }; pub use scheduler::{ diff --git a/crates/opensymphony-orchestrator/src/parent_integration.rs b/crates/opensymphony-orchestrator/src/parent_integration.rs index 3ae30f21f..6edaf734d 100644 --- a/crates/opensymphony-orchestrator/src/parent_integration.rs +++ b/crates/opensymphony-orchestrator/src/parent_integration.rs @@ -1,4 +1,7 @@ -use std::{collections::BTreeMap, path::PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use crate::opensymphony_domain::{ CanonicalRepositoryId, IssueId, ParentVerificationEvidence, TimestampMs, @@ -13,6 +16,7 @@ const MAX_ATTEMPTS: usize = 64; const MAX_LOG_BYTES: usize = 16 * 1024; const MAX_COMMAND_BYTES: usize = 4 * 1024; const MAX_COMMAND_ID_BYTES: usize = 256; +const MAX_REPAIR_FAILURE_BYTES: usize = 4 * 1024; const MAX_RESOURCE_RECEIPTS: usize = 64; const MAX_COMMAND_RECEIPTS: usize = MAX_RESOURCE_RECEIPTS / 2; @@ -25,12 +29,33 @@ pub enum ParentIntegrationState { PreparingIntegrationWorkspace, RefreshingRepositories, Integrating, + Fixing { + repository_id: CanonicalRepositoryId, + repair_attempt: u64, + }, + AwaitingFixReview { + repository_id: CanonicalRepositoryId, + repair_attempt: u64, + pull_request_id: String, + }, + AwaitingFixMerge { + repository_id: CanonicalRepositoryId, + repair_attempt: u64, + pull_request_id: String, + }, + RefreshingAfterFixes, FinalVerification, Finalizing, Completed, - Blocked { reason: String }, - Failed { reason: String }, - Canceled { reason: String }, + Blocked { + reason: String, + }, + Failed { + reason: String, + }, + Canceled { + reason: String, + }, } impl ParentIntegrationState { @@ -187,6 +212,10 @@ pub struct ParentVerificationAttempt { pub resources: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub cleanup: Option, + /// Orchestrator-observed proof that a conversation-bound harness turn + /// reached a terminal state or acknowledged an interrupt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_stopped_at: Option, pub input_version: String, #[serde(default)] pub verified_repository_commits: BTreeMap, @@ -198,7 +227,190 @@ pub struct ParentRepositoryTarget { pub checkout_handle: String, #[serde(default)] pub relative_path: PathBuf, + #[serde(default = "default_parent_target_branch")] + pub target_branch: String, + pub target_commit: String, + #[serde(default)] + pub instruction_path: PathBuf, + #[serde(default)] + pub instruction_hash: String, + #[serde(default)] + pub repair_policy: ParentRepairPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentRepairPolicy { + pub review_profile: String, + pub review_provider: String, + pub review_policy_generation: String, + pub required_checks: bool, + pub required_review: bool, + pub merge_method: String, +} + +fn default_parent_target_branch() -> String { + String::new() +} + +impl Default for ParentRepairPolicy { + fn default() -> Self { + Self { + review_profile: "legacy-default".to_owned(), + review_provider: "github".to_owned(), + review_policy_generation: "schema-1-default".to_owned(), + required_checks: true, + required_review: true, + merge_method: "merge".to_owned(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentRepairStatus { + PreparingBranch, + Implementing, + AwaitingPullRequest, + AwaitingReview, + ChangesRequested, + AwaitingMerge, + Refreshing, + Completed, + FailedChecks, + ReviewRejected, + ReviewBudgetExhausted, + ProviderUnavailable, + ExternallyClosed, + ForcePushed, + MergeConflict, +} + +impl ParentRepairStatus { + pub fn resumable(self) -> bool { + !matches!(self, Self::Completed) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentProviderOperationKind { + ReconcileBranch, + CreateBranch, + ReconcilePush, + Push, + ReconcilePullRequest, + CreatePullRequest, + ReconcileReview, + RequestReview, + ReconcileMerge, + Merge, + RefreshTarget, +} + +impl ParentProviderOperationKind { + fn prerequisite(self) -> Option { + match self { + Self::CreateBranch => Some(Self::ReconcileBranch), + Self::Push => Some(Self::ReconcilePush), + Self::CreatePullRequest => Some(Self::ReconcilePullRequest), + Self::RequestReview => Some(Self::ReconcileReview), + Self::Merge => Some(Self::ReconcileMerge), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentProviderOperation { + pub kind: ParentProviderOperationKind, + pub idempotency_key: String, + pub input_version: String, + pub intended_at: TimestampMs, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentRepairAttempt { + pub id: String, + pub number: u64, + pub repository_id: CanonicalRepositoryId, + pub checkout_handle: String, + pub target_branch: String, pub target_commit: String, + pub instruction_path: PathBuf, + pub instruction_hash: String, + pub branch: String, + pub lease_id: String, + pub policy: ParentRepairPolicy, + #[serde(default)] + pub implementation_completed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_advancement_failure: Option, + pub status: ParentRepairStatus, + pub created_at: TimestampMs, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pushed_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_result_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refreshed_target_commit: Option, + #[serde(default)] + pub requested_change_count: u32, + /// Bounded provider-owned feedback for the current pushed commit. This is + /// persisted so a resumed repair worker does not need direct provider + /// credentials to learn what the central review requested. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub review_feedback: Vec, + #[serde(default)] + pub operations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentReviewFeedback { + pub thread_id: String, + pub body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentRepairAdvancementFailure { + pub occurred_at: TimestampMs, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentRepairProviderSnapshot { + pub pull_request_id: Option, + pub pull_request_url: Option, + pub head_commit: Option, + pub review_head_commit: Option, + /// Provider cursor observed before a review request is written. GitHub + /// uses the highest issue-comment ID so a crash after an exact trigger can + /// reconcile the write without relying on second-precision timestamps. + pub review_request_cursor: Option, + pub open: bool, + pub checks_passed: bool, + pub checks_failed: bool, + pub review_approved: bool, + pub review_rejected: bool, + pub changes_requested: bool, + pub review_feedback: Vec, + pub mergeable: bool, + pub merge_conflict: bool, + pub merged: bool, + pub merge_result_commit: Option, + pub target_contains_merge_result: bool, + pub provider_available: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -224,6 +436,8 @@ pub struct ParentIntegrationController { #[serde(default)] pub next_attempt_sequence: u64, #[serde(default)] + pub next_repair_sequence: u64, + #[serde(default)] pub transitions: Vec, #[serde(default)] pub targets: BTreeMap, @@ -231,6 +445,8 @@ pub struct ParentIntegrationController { pub conversation_id: Option, #[serde(default)] pub attempts: Vec, + #[serde(default)] + pub repair_attempts: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub final_evidence: Option, } @@ -241,8 +457,8 @@ pub enum ParentIntegrationError { InvalidGeneration, #[error("invalid parent transition from {from:?} to {to:?}")] InvalidTransition { - from: ParentIntegrationState, - to: ParentIntegrationState, + from: Box, + to: Box, }, #[error("idempotency key `{0}` was reused with different transition facts")] IdempotencyConflict(String), @@ -252,6 +468,17 @@ pub enum ParentIntegrationError { UnknownCheckoutHandle(String), #[error("verification attempt `{0}` was not found")] UnknownAttempt(String), + #[error("repair attempt `{0}` was not found")] + UnknownRepairAttempt(String), + #[error("repository `{0}` is not present in the verified parent target map")] + UnknownRepairRepository(String), + #[error("repair attempt `{0}` does not match its verified checkout target")] + RepairTargetMismatch(String), + #[error("provider operation `{operation:?}` requires a completed `{prerequisite:?}` lookup")] + ProviderReconciliationRequired { + operation: ParentProviderOperationKind, + prerequisite: ParentProviderOperationKind, + }, #[error("verification attempt `{0}` is already terminal")] AttemptAlreadyTerminal(String), #[error("verification attempt `{0}` cannot start before cleanup and baseline verification")] @@ -279,14 +506,47 @@ impl ParentIntegrationController { state: ParentIntegrationState::WaitingForChildren, admission_input_version: None, next_attempt_sequence: 0, + next_repair_sequence: 0, transitions: Vec::new(), targets: BTreeMap::new(), conversation_id: None, attempts: Vec::new(), + repair_attempts: Vec::new(), final_evidence: None, }) } + pub fn migrate_legacy_repair_target( + &mut self, + current: ParentRepositoryTarget, + ) -> Result { + let existing = self + .targets + .get_mut(¤t.repository_id) + .ok_or_else(|| { + ParentIntegrationError::UnknownRepairRepository(current.repository_id.to_string()) + })?; + let legacy = existing.target_branch.is_empty() + && existing.repair_policy == ParentRepairPolicy::default(); + if !legacy { + return Ok(false); + } + if existing.checkout_handle != current.checkout_handle + || existing.relative_path != current.relative_path + || existing.target_commit != current.target_commit + || (!existing.instruction_path.as_os_str().is_empty() + && existing.instruction_path != current.instruction_path) + || (!existing.instruction_hash.is_empty() + && existing.instruction_hash != current.instruction_hash) + { + return Err(ParentIntegrationError::RepairTargetMismatch( + current.repository_id.to_string(), + )); + } + *existing = current; + Ok(true) + } + pub fn admit( &mut self, input_version: &str, @@ -482,7 +742,9 @@ impl ParentIntegrationController { } if !matches!( self.state, - ParentIntegrationState::Integrating | ParentIntegrationState::FinalVerification + ParentIntegrationState::Integrating + | ParentIntegrationState::FinalVerification + | ParentIntegrationState::Fixing { .. } ) || !self.cleanup_complete() { return Err(ParentIntegrationError::AttemptNotReady(name)); @@ -527,6 +789,7 @@ impl ParentIntegrationController { log_truncated: false, resources: Vec::new(), cleanup: None, + harness_stopped_at: None, input_version, verified_repository_commits: BTreeMap::new(), }); @@ -800,6 +1063,7 @@ impl ParentIntegrationController { )?; } let attempt = self.attempt_mut(attempt_id)?; + attempt.harness_stopped_at = Some(observed_at); let remaining = active_resource_keys(attempt); attempt.cleanup = Some(ParentCleanupReceipt { status: if remaining.is_empty() { @@ -1024,11 +1288,15 @@ impl ParentIntegrationController { "harness state and attempt-owned teardown were unavailable after restart".to_owned() }), }); - self.prepare_retry( - &attempt_id, - "nonterminal parent attempt became indeterminate after restart", - occurred_at, - ) + if self.repair_implementation_in_progress() { + Ok(()) + } else { + self.prepare_retry( + &attempt_id, + "nonterminal parent attempt became indeterminate after restart", + occurred_at, + ) + } } pub fn reconcile_terminal_run_after_restart( @@ -1046,6 +1314,7 @@ impl ParentIntegrationController { let resources_released = active_resource_keys(&self.attempts[index]).is_empty(); self.attempts[index].status = ParentAttemptStatus::Indeterminate; self.attempts[index].finished_at = Some(occurred_at); + self.attempts[index].harness_stopped_at = Some(occurred_at); self.attempts[index].cleanup = Some(ParentCleanupReceipt { status: if resources_released { ParentCleanupStatus::Succeeded @@ -1060,151 +1329,962 @@ impl ParentIntegrationController { .to_owned() }), }); - self.prepare_retry( - &attempt_id, - "terminal harness outcome was not durably applied before restart", - occurred_at, - ) + if self.repair_implementation_in_progress() { + Ok(()) + } else { + self.prepare_retry( + &attempt_id, + "terminal harness outcome was not durably applied before restart", + occurred_at, + ) + } } - pub fn complete( + fn repair_implementation_in_progress(&self) -> bool { + matches!(self.state, ParentIntegrationState::Fixing { .. }) + && self.repair_attempts.iter().any(|repair| { + matches!( + repair.status, + ParentRepairStatus::Implementing | ParentRepairStatus::ChangesRequested + ) && !repair.implementation_completed + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn begin_repair( &mut self, - attempt_id: &str, + idempotency_key: &str, + repository_id: CanonicalRepositoryId, + checkout_handle: &str, + target_commit: &str, input_version: &str, occurred_at: TimestampMs, - ) -> Result<(), ParentIntegrationError> { - let expected_commits = self - .targets - .iter() - .map(|(repository_id, target)| (repository_id.clone(), target.target_commit.clone())) - .collect::>(); - if !self.attempts.iter().any(|attempt| { - attempt.id == attempt_id - && attempt.status.passed() - && attempt.input_version == input_version - && attempt.exit_code == Some(0) - && attempt - .cleanup - .as_ref() - .is_some_and(|cleanup| cleanup.status == ParentCleanupStatus::Succeeded) - && attempt.verified_repository_commits == expected_commits + ) -> Result { + if let Some(existing) = self.repair_attempts.iter().find(|attempt| { + attempt + .operations + .first() + .is_some_and(|operation| operation.idempotency_key == idempotency_key) }) { - return Err(ParentIntegrationError::FinalVerificationIncomplete); + return if existing.repository_id == repository_id + && existing.checkout_handle == checkout_handle + && existing.target_commit == target_commit + { + Ok(existing.id.clone()) + } else { + Err(ParentIntegrationError::IdempotencyConflict( + idempotency_key.to_owned(), + )) + }; + } + if self.state != ParentIntegrationState::Integrating { + return Err(ParentIntegrationError::InvalidTransition { + from: Box::new(self.state.clone()), + to: Box::new(ParentIntegrationState::Fixing { + repository_id, + repair_attempt: self.next_repair_sequence.saturating_add(1), + }), + }); + } + let target = self.targets.get(&repository_id).ok_or_else(|| { + ParentIntegrationError::UnknownRepairRepository(repository_id.to_string()) + })?; + if target.checkout_handle != checkout_handle || target.target_commit != target_commit { + return Err(ParentIntegrationError::RepairTargetMismatch( + repository_id.to_string(), + )); } + let instruction_path = target.instruction_path.clone(); + let instruction_hash = target.instruction_hash.clone(); + let target_branch = target.target_branch.clone(); + let policy = target.repair_policy.clone(); + self.next_repair_sequence = self.next_repair_sequence.saturating_add(1); + let number = self.next_repair_sequence; + let id = format!("parent-repair-{number}"); + let branch_parent = self + .parent_id + .as_str() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_owned(); + let branch = format!( + "fix/{branch_parent}-g{}-repair-{number}", + self.hierarchy_generation + ); + let lease_id = format!( + "repair:{}:{}:{}", + self.parent_id, self.hierarchy_generation, number + ); self.transition( - ParentIntegrationState::FinalVerification, - "parent integration attempt passed", - format!("{attempt_id}:final-verification"), + ParentIntegrationState::Fixing { + repository_id: repository_id.clone(), + repair_attempt: number, + }, + "integration defect requires a repository repair", + idempotency_key, input_version, - Some(intent( - "accept_harness_final_verification", - format!("{attempt_id}:final-verification"), - )), - Some(receipt("passed", None)), + Some(intent("acquire_repair_lease", idempotency_key)), + None, ParentRetryClassification::Retryable, occurred_at, )?; - self.final_evidence = Some(ParentFinalEvidence { - attempt_id: attempt_id.to_owned(), - conversation_id: self.conversation_id.clone().unwrap_or_default(), - input_version: input_version.to_owned(), - repository_commits: expected_commits, - recorded_at: occurred_at, + self.repair_attempts.push(ParentRepairAttempt { + id: id.clone(), + number, + repository_id, + checkout_handle: checkout_handle.to_owned(), + target_branch, + target_commit: target_commit.to_owned(), + instruction_path, + instruction_hash, + branch, + lease_id, + policy, + implementation_completed: false, + last_advancement_failure: None, + status: ParentRepairStatus::PreparingBranch, + created_at: occurred_at, + pushed_commit: None, + pull_request_id: None, + pull_request_url: None, + merge_result_commit: None, + refreshed_target_commit: None, + requested_change_count: 0, + review_feedback: Vec::new(), + operations: vec![ParentProviderOperation { + kind: ParentProviderOperationKind::ReconcileBranch, + idempotency_key: idempotency_key.to_owned(), + input_version: input_version.to_owned(), + intended_at: occurred_at, + receipt: None, + completed_at: None, + }], }); - self.transition( - ParentIntegrationState::Finalizing, - "final verification evidence was written durably", - format!("{attempt_id}:finalize"), - input_version, - Some(intent( - "persist_final_evidence", - format!("{attempt_id}:finalize"), - )), - Some(receipt("succeeded", None)), - ParentRetryClassification::Retryable, - occurred_at, - )?; - self.transition( - ParentIntegrationState::Completed, - "all recorded repository targets passed final verification", - format!("{attempt_id}:complete"), - input_version, - None, - Some(receipt("succeeded", None)), - ParentRetryClassification::Terminal, - occurred_at, - )?; - Ok(()) + Ok(id) } - pub fn cancel( + pub fn record_repair_advancement_failure( &mut self, - reason: impl Into, - input_version: &str, - acknowledged: bool, + repair_id: &str, + detail: &str, occurred_at: TimestampMs, ) -> Result<(), ParentIntegrationError> { - let reason = reason.into(); - self.transition( - ParentIntegrationState::Canceled { - reason: reason.clone(), - }, - reason, - format!( - "parent:{}:{}:cancel", - self.parent_id, self.hierarchy_generation - ), - input_version, - Some(intent( - "interrupt_parent_harness", - format!( - "parent:{}:{}:cancel", - self.parent_id, self.hierarchy_generation - ), - )), - Some(receipt( - if acknowledged { - "acknowledged" - } else { - "uncertain" - }, - None, - )), - ParentRetryClassification::Terminal, - occurred_at, - )?; + let mut boundary = detail.len().min(MAX_REPAIR_FAILURE_BYTES); + while boundary > 0 && !detail.is_char_boundary(boundary) { + boundary -= 1; + } + self.repair_mut(repair_id)?.last_advancement_failure = + Some(ParentRepairAdvancementFailure { + occurred_at, + detail: detail[..boundary].to_owned(), + }); Ok(()) } - pub fn current_attempt_id(&self) -> Option<&str> { - self.attempts + pub fn begin_provider_operation( + &mut self, + repair_id: &str, + kind: ParentProviderOperationKind, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result { + let repair = self.repair_mut(repair_id)?; + if let Some(prerequisite) = kind.prerequisite() + && !repair + .operations + .iter() + .any(|operation| operation.kind == prerequisite && operation.receipt.is_some()) + { + return Err(ParentIntegrationError::ProviderReconciliationRequired { + operation: kind, + prerequisite, + }); + } + if let Some(existing) = repair + .operations .iter() .rev() - .find(|attempt| attempt.status == ParentAttemptStatus::Running) - .map(|attempt| attempt.id.as_str()) - } - - pub fn current_attempt_deadline(&self) -> Option { - self.attempts + .find(|operation| operation.kind == kind && operation.receipt.is_none()) + { + return if existing.input_version == input_version { + Ok(existing.idempotency_key.clone()) + } else { + Err(ParentIntegrationError::IdempotencyConflict( + existing.idempotency_key.clone(), + )) + }; + } + let sequence = repair + .operations .iter() - .rev() - .find(|attempt| attempt.status == ParentAttemptStatus::Running) - .map(|attempt| { - TimestampMs::new( - attempt - .started_at - .as_u64() - .saturating_add(attempt.timeout_ms), - ) - }) + .filter(|operation| operation.kind == kind) + .count() + .saturating_add(1); + let key = format!("{}:{kind:?}:{sequence}", repair.id).to_ascii_lowercase(); + repair.operations.push(ParentProviderOperation { + kind, + idempotency_key: key.clone(), + input_version: input_version.to_owned(), + intended_at: occurred_at, + receipt: None, + completed_at: None, + }); + Ok(key) } - fn cleanup_complete(&self) -> bool { - self.attempts.iter().all(|attempt| match attempt.status { - ParentAttemptStatus::Running => false, - ParentAttemptStatus::Passed - | ParentAttemptStatus::Failed - | ParentAttemptStatus::TimedOut + pub fn record_provider_operation( + &mut self, + repair_id: &str, + idempotency_key: &str, + status: &str, + detail: Option, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let operation = self + .repair_mut(repair_id)? + .operations + .iter_mut() + .find(|operation| operation.idempotency_key == idempotency_key) + .ok_or_else(|| { + ParentIntegrationError::IdempotencyConflict(idempotency_key.to_owned()) + })?; + let next = receipt(status, detail); + if let Some(existing) = &operation.receipt { + return if existing == &next { + Ok(()) + } else { + Err(ParentIntegrationError::IdempotencyConflict( + idempotency_key.to_owned(), + )) + }; + } + operation.receipt = Some(next); + operation.completed_at = Some(occurred_at); + Ok(()) + } + + pub fn record_repair_branch_ready( + &mut self, + repair_id: &str, + ) -> Result<(), ParentIntegrationError> { + self.repair_mut(repair_id)?.status = ParentRepairStatus::Implementing; + Ok(()) + } + + pub fn record_repair_implementation_completed( + &mut self, + repair_id: &str, + ) -> Result<(), ParentIntegrationError> { + let repair = self.repair_mut(repair_id)?; + if !matches!( + repair.status, + ParentRepairStatus::Implementing | ParentRepairStatus::ChangesRequested + ) { + return Err(ParentIntegrationError::RepairTargetMismatch( + repair_id.to_owned(), + )); + } + repair.status = ParentRepairStatus::Implementing; + repair.implementation_completed = true; + Ok(()) + } + + pub fn record_repair_implementation_required( + &mut self, + repair_id: &str, + ) -> Result<(), ParentIntegrationError> { + let repair = self.repair_mut(repair_id)?; + repair.status = ParentRepairStatus::Implementing; + repair.implementation_completed = false; + Ok(()) + } + + pub fn record_repair_review_budget_exhausted( + &mut self, + repair_id: &str, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + self.repair_mut(repair_id)?.status = ParentRepairStatus::ReviewBudgetExhausted; + self.block_repair( + repair_id, + "configured automated review budget is exhausted; exact-commit local review and operator action are required", + input_version, + occurred_at, + ) + } + + pub fn record_repair_push( + &mut self, + repair_id: &str, + commit: &str, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let was_fixing = matches!(self.state, ParentIntegrationState::Fixing { .. }); + let review_state = { + let repair = self.repair_mut(repair_id)?; + let unchanged = repair.pushed_commit.as_deref() == Some(commit); + if !unchanged { + for operation in repair.operations.iter_mut().filter(|operation| { + matches!( + operation.kind, + ParentProviderOperationKind::ReconcileMerge + | ParentProviderOperationKind::Merge + ) && operation.receipt.is_none() + }) { + operation.receipt = Some(receipt("superseded", None)); + operation.completed_at = Some(occurred_at); + } + } + repair.pushed_commit = Some(commit.to_owned()); + if !unchanged { + repair.review_feedback.clear(); + } + repair.status = if repair.pull_request_id.is_some() { + ParentRepairStatus::AwaitingReview + } else { + ParentRepairStatus::AwaitingPullRequest + }; + (!unchanged || was_fixing) + .then(|| repair.pull_request_id.clone()) + .flatten() + .map( + |pull_request_id| ParentIntegrationState::AwaitingFixReview { + repository_id: repair.repository_id.clone(), + repair_attempt: repair.number, + pull_request_id, + }, + ) + }; + if let Some(review_state) = review_state { + self.transition( + review_state, + "requested changes were pushed to the existing repair pull request", + format!("{repair_id}:push:{commit}"), + input_version, + Some(intent("push_repair", format!("{repair_id}:push:{commit}"))), + Some(receipt("pushed", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + } + Ok(()) + } + + pub fn record_repair_pull_request( + &mut self, + repair_id: &str, + pull_request_id: &str, + pull_request_url: &str, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let repair = self.repair(repair_id)?.clone(); + if let Some(existing_pull_request_id) = repair.pull_request_id.as_deref() { + return if existing_pull_request_id == pull_request_id + && repair.pull_request_url.as_deref() == Some(pull_request_url) + { + Ok(()) + } else { + Err(ParentIntegrationError::RepairTargetMismatch( + repair_id.to_owned(), + )) + }; + } + self.transition( + ParentIntegrationState::AwaitingFixReview { + repository_id: repair.repository_id, + repair_attempt: repair.number, + pull_request_id: pull_request_id.to_owned(), + }, + "repair pull request is awaiting configured checks and review", + format!("{repair_id}:pull-request:{pull_request_id}"), + input_version, + Some(intent( + "reconcile_pull_request", + format!("{repair_id}:pull-request:{pull_request_id}"), + )), + Some(receipt("found_or_created", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + let repair = self.repair_mut(repair_id)?; + repair.pull_request_id = Some(pull_request_id.to_owned()); + repair.pull_request_url = Some(pull_request_url.to_owned()); + repair.status = ParentRepairStatus::AwaitingReview; + Ok(()) + } + + pub fn reconcile_repair_provider( + &mut self, + repair_id: &str, + snapshot: ParentRepairProviderSnapshot, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let current = self.repair(repair_id)?.clone(); + if !snapshot.provider_available { + self.repair_mut(repair_id)?.status = ParentRepairStatus::ProviderUnavailable; + return self.block_repair( + repair_id, + "provider unavailable", + input_version, + occurred_at, + ); + } + if snapshot.pull_request_id.is_none() + && current.pull_request_id.is_none() + && current.pushed_commit.is_some() + && matches!(self.state, ParentIntegrationState::Blocked { .. }) + { + self.repair_mut(repair_id)?.status = ParentRepairStatus::AwaitingPullRequest; + self.transition( + ParentIntegrationState::Fixing { + repository_id: current.repository_id, + repair_attempt: current.number, + }, + "provider recovered before repair pull-request creation", + format!("{repair_id}:pull-request-recovered:{}", self.state_version), + input_version, + Some(intent( + "reconcile_pull_request", + format!("{repair_id}:pull-request-recovered:{}", self.state_version), + )), + Some(receipt("missing", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + return Ok(()); + } + if snapshot.merged { + let merge_commit = snapshot + .merge_result_commit + .filter(|commit| !commit.trim().is_empty()) + .ok_or_else(|| { + ParentIntegrationError::RepairTargetMismatch(repair_id.to_owned()) + })?; + let review_is_current = + snapshot.review_head_commit.as_deref() == current.pushed_commit.as_deref(); + let checks_satisfied = !current.policy.required_checks || snapshot.checks_passed; + let review_satisfied = !current.policy.required_review + || (snapshot.review_approved + && review_is_current + && !snapshot.review_rejected + && !snapshot.changes_requested); + let head_is_current = + snapshot.head_commit.as_deref() == current.pushed_commit.as_deref(); + let merge_input_version = format!( + "{input_version};repair-head:{}", + current.pushed_commit.as_deref().unwrap_or("missing") + ); + let pending_merge = current.operations.iter().rev().find(|operation| { + operation.kind == ParentProviderOperationKind::Merge + && operation.input_version == merge_input_version + && operation.receipt.is_none() + }); + if !head_is_current || !checks_satisfied || !review_satisfied || pending_merge.is_none() + { + self.repair_mut(repair_id)?.status = ParentRepairStatus::ExternallyClosed; + return self.block_repair( + repair_id, + "provider reports an externally merged repair without a current eligible orchestrator merge intent", + input_version, + occurred_at, + ); + } + if !snapshot.target_contains_merge_result { + self.repair_mut(repair_id)?.status = ParentRepairStatus::ForcePushed; + return self.block_repair( + repair_id, + "provider merge result is not reachable from target", + input_version, + occurred_at, + ); + } + let merge_key = pending_merge + .expect("pending merge was checked") + .idempotency_key + .clone(); + self.record_provider_operation( + repair_id, + &merge_key, + "merged", + Some(merge_commit.clone()), + occurred_at, + )?; + let repair = self.repair_mut(repair_id)?; + repair.status = ParentRepairStatus::Refreshing; + repair.merge_result_commit = Some(merge_commit); + self.transition( + ParentIntegrationState::RefreshingAfterFixes, + "provider reports a reachable repair merge result", + format!("{repair_id}:merged"), + input_version, + Some(intent( + "refresh_after_repair", + format!("{repair_id}:merged"), + )), + Some(receipt("merged", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + return Ok(()); + } + if !snapshot.open && snapshot.pull_request_id.is_some() { + self.repair_mut(repair_id)?.status = ParentRepairStatus::ExternallyClosed; + return self.block_repair( + repair_id, + "repair pull request was closed externally", + input_version, + occurred_at, + ); + } + if snapshot + .head_commit + .as_deref() + .zip(current.pushed_commit.as_deref()) + .is_some_and(|(actual, expected)| actual != expected) + { + self.repair_mut(repair_id)?.status = ParentRepairStatus::ForcePushed; + return self.block_repair( + repair_id, + "repair branch was force-pushed outside the recorded attempt", + input_version, + occurred_at, + ); + } + if snapshot.merge_conflict { + self.repair_mut(repair_id)?.status = ParentRepairStatus::MergeConflict; + return self.block_repair( + repair_id, + "repair merge conflict", + input_version, + occurred_at, + ); + } + if current.policy.required_checks && snapshot.checks_failed { + self.repair_mut(repair_id)?.status = ParentRepairStatus::FailedChecks; + return self.block_repair( + repair_id, + "required checks failed", + input_version, + occurred_at, + ); + } + let review_is_current = + snapshot.review_head_commit.as_deref() == current.pushed_commit.as_deref(); + if review_is_current { + self.repair_mut(repair_id)?.review_feedback = snapshot.review_feedback.clone(); + } + if current.policy.required_review && snapshot.changes_requested && review_is_current { + if current.status == ParentRepairStatus::ChangesRequested { + return Ok(()); + } + let (repository_id, number, change_number) = { + let repair = self.repair_mut(repair_id)?; + repair.status = ParentRepairStatus::ChangesRequested; + repair.implementation_completed = false; + repair.requested_change_count = repair.requested_change_count.saturating_add(1); + ( + repair.repository_id.clone(), + repair.number, + repair.requested_change_count, + ) + }; + self.transition( + ParentIntegrationState::Fixing { + repository_id, + repair_attempt: number, + }, + "provider review requested changes in the existing repair attempt", + format!("{repair_id}:changes-requested:{change_number}"), + input_version, + None, + Some(receipt("changes_requested", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + return Ok(()); + } + if current.policy.required_review + && current.status == ParentRepairStatus::ChangesRequested + && review_is_current + { + let pull_request_id = current.pull_request_id.clone().ok_or_else(|| { + ParentIntegrationError::RepairTargetMismatch(repair_id.to_owned()) + })?; + self.repair_mut(repair_id)?.status = ParentRepairStatus::AwaitingReview; + self.transition( + ParentIntegrationState::AwaitingFixReview { + repository_id: current.repository_id.clone(), + repair_attempt: current.number, + pull_request_id, + }, + "provider feedback was resolved on the unchanged repair head", + format!("{repair_id}:feedback-resolved:{}", self.state_version), + input_version, + Some(intent( + "reconcile_provider", + format!("{repair_id}:feedback-resolved:{}", self.state_version), + )), + Some(receipt("resolved", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + } + if current.policy.required_review && snapshot.review_rejected && review_is_current { + self.repair_mut(repair_id)?.status = ParentRepairStatus::ReviewRejected; + return self.block_repair( + repair_id, + "repair review was rejected", + input_version, + occurred_at, + ); + } + let checks_satisfied = !current.policy.required_checks || snapshot.checks_passed; + let review_satisfied = + !current.policy.required_review || (snapshot.review_approved && review_is_current); + if checks_satisfied && review_satisfied && snapshot.mergeable { + let pull_request_id = current.pull_request_id.ok_or_else(|| { + ParentIntegrationError::RepairTargetMismatch(repair_id.to_owned()) + })?; + self.repair_mut(repair_id)?.status = ParentRepairStatus::AwaitingMerge; + self.transition( + ParentIntegrationState::AwaitingFixMerge { + repository_id: current.repository_id, + repair_attempt: current.number, + pull_request_id, + }, + "central repair review policy is satisfied", + format!("{repair_id}:merge-ready"), + input_version, + Some(intent("merge_repair", format!("{repair_id}:merge-ready"))), + Some(receipt("eligible", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + } else if current.status != ParentRepairStatus::ReviewBudgetExhausted + && matches!( + self.state, + ParentIntegrationState::Blocked { .. } + | ParentIntegrationState::AwaitingFixMerge { .. } + ) + && snapshot.open + { + let pull_request_id = current.pull_request_id.ok_or_else(|| { + ParentIntegrationError::RepairTargetMismatch(repair_id.to_owned()) + })?; + self.repair_mut(repair_id)?.status = ParentRepairStatus::AwaitingReview; + self.transition( + ParentIntegrationState::AwaitingFixReview { + repository_id: current.repository_id, + repair_attempt: current.number, + pull_request_id, + }, + "provider reconciliation requires a current eligible review snapshot", + format!("{repair_id}:provider-recovered:{}", self.state_version), + input_version, + Some(intent( + "reconcile_provider", + format!("{repair_id}:provider-recovered:{}", self.state_version), + )), + Some(receipt("recovered", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + } + Ok(()) + } + + pub fn record_repair_refresh( + &mut self, + repair_id: &str, + refreshed_target_commit: &str, + refreshed_instruction_path: &Path, + refreshed_instruction_hash: &str, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let repository_id = { + let repair = self.repair_mut(repair_id)?; + if repair.merge_result_commit.is_none() || refreshed_target_commit.trim().is_empty() { + return Err(ParentIntegrationError::RepairTargetMismatch( + repair_id.to_owned(), + )); + } + repair.status = ParentRepairStatus::Completed; + repair.refreshed_target_commit = Some(refreshed_target_commit.to_owned()); + repair.repository_id.clone() + }; + let target = self.targets.get_mut(&repository_id).ok_or_else(|| { + ParentIntegrationError::UnknownRepairRepository(repository_id.to_string()) + })?; + target.target_commit = refreshed_target_commit.to_owned(); + target.instruction_path = refreshed_instruction_path.to_path_buf(); + target.instruction_hash = refreshed_instruction_hash.to_owned(); + self.transition( + ParentIntegrationState::Integrating, + "affected integration checkout refreshed to the provider merge result", + format!("{repair_id}:refreshed:{refreshed_target_commit}"), + input_version, + Some(intent( + "verify_refreshed_target", + format!("{repair_id}:refreshed:{refreshed_target_commit}"), + )), + Some(receipt("reachable", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + Ok(()) + } + + pub fn repair(&self, repair_id: &str) -> Result<&ParentRepairAttempt, ParentIntegrationError> { + self.repair_attempts + .iter() + .find(|repair| repair.id == repair_id) + .ok_or_else(|| ParentIntegrationError::UnknownRepairAttempt(repair_id.to_owned())) + } + + fn repair_mut( + &mut self, + repair_id: &str, + ) -> Result<&mut ParentRepairAttempt, ParentIntegrationError> { + self.repair_attempts + .iter_mut() + .find(|repair| repair.id == repair_id) + .ok_or_else(|| ParentIntegrationError::UnknownRepairAttempt(repair_id.to_owned())) + } + + fn block_repair( + &mut self, + repair_id: &str, + reason: &str, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + self.transition( + ParentIntegrationState::Blocked { + reason: reason.to_owned(), + }, + reason, + format!("{repair_id}:blocked:{reason}"), + input_version, + Some(intent( + "reconcile_provider", + format!("{repair_id}:blocked:{reason}"), + )), + Some(receipt("blocked", Some(reason.to_owned()))), + ParentRetryClassification::OperatorAction, + occurred_at, + )?; + Ok(()) + } + + pub fn complete( + &mut self, + attempt_id: &str, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let expected_commits = self + .targets + .iter() + .map(|(repository_id, target)| (repository_id.clone(), target.target_commit.clone())) + .collect::>(); + if !self.attempts.iter().any(|attempt| { + attempt.id == attempt_id + && attempt.status.passed() + && attempt.input_version == input_version + && attempt.exit_code == Some(0) + && attempt + .cleanup + .as_ref() + .is_some_and(|cleanup| cleanup.status == ParentCleanupStatus::Succeeded) + && attempt.verified_repository_commits == expected_commits + }) { + return Err(ParentIntegrationError::FinalVerificationIncomplete); + } + self.transition( + ParentIntegrationState::FinalVerification, + "parent integration attempt passed", + format!("{attempt_id}:final-verification"), + input_version, + Some(intent( + "accept_harness_final_verification", + format!("{attempt_id}:final-verification"), + )), + Some(receipt("passed", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + self.final_evidence = Some(ParentFinalEvidence { + attempt_id: attempt_id.to_owned(), + conversation_id: self.conversation_id.clone().unwrap_or_default(), + input_version: input_version.to_owned(), + repository_commits: expected_commits, + recorded_at: occurred_at, + }); + self.transition( + ParentIntegrationState::Finalizing, + "final verification evidence was written durably", + format!("{attempt_id}:finalize"), + input_version, + Some(intent( + "persist_final_evidence", + format!("{attempt_id}:finalize"), + )), + Some(receipt("succeeded", None)), + ParentRetryClassification::Retryable, + occurred_at, + )?; + self.transition( + ParentIntegrationState::Completed, + "all recorded repository targets passed final verification", + format!("{attempt_id}:complete"), + input_version, + None, + Some(receipt("succeeded", None)), + ParentRetryClassification::Terminal, + occurred_at, + )?; + Ok(()) + } + + pub fn cancel( + &mut self, + reason: impl Into, + input_version: &str, + acknowledged: bool, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let reason = reason.into(); + self.transition( + ParentIntegrationState::Canceled { + reason: reason.clone(), + }, + reason, + format!( + "parent:{}:{}:cancel", + self.parent_id, self.hierarchy_generation + ), + input_version, + Some(intent( + "interrupt_parent_harness", + format!( + "parent:{}:{}:cancel", + self.parent_id, self.hierarchy_generation + ), + )), + Some(receipt( + if acknowledged { + "acknowledged" + } else { + "uncertain" + }, + None, + )), + ParentRetryClassification::Terminal, + occurred_at, + )?; + Ok(()) + } + + pub fn cancel_without_harness( + &mut self, + reason: impl Into, + input_version: &str, + occurred_at: TimestampMs, + ) -> Result<(), ParentIntegrationError> { + let reason = reason.into(); + self.transition( + ParentIntegrationState::Canceled { + reason: reason.clone(), + }, + reason, + format!( + "parent:{}:{}:cancel", + self.parent_id, self.hierarchy_generation + ), + input_version, + None, + None, + ParentRetryClassification::Terminal, + occurred_at, + )?; + Ok(()) + } + + pub fn current_attempt_id(&self) -> Option<&str> { + self.attempts + .iter() + .rev() + .find(|attempt| attempt.status == ParentAttemptStatus::Running) + .map(|attempt| attempt.id.as_str()) + } + + pub fn can_cancel_without_harness(&self) -> bool { + self.current_attempt_id().is_none() + && self.attempts.iter().all(|attempt| { + let cleanup_succeeded = attempt + .cleanup + .as_ref() + .is_some_and(|cleanup| cleanup.status == ParentCleanupStatus::Succeeded) + && active_resource_keys(attempt).is_empty(); + cleanup_succeeded + && match attempt.conversation_id.as_ref() { + Some(_) => attempt.harness_stopped_at.is_some(), + None => { + attempt.status != ParentAttemptStatus::Indeterminate + || attempt.commands.is_empty() + } + } + }) + } + + pub fn has_unreconciled_harness(&self) -> bool { + self.attempts.iter().any(|attempt| { + attempt.conversation_id.is_some() + && attempt.harness_stopped_at.is_none() + && matches!( + attempt.status, + ParentAttemptStatus::Running + | ParentAttemptStatus::Failed + | ParentAttemptStatus::TimedOut + | ParentAttemptStatus::Indeterminate + ) + }) + } + + pub fn current_attempt_deadline(&self) -> Option { + self.attempts + .iter() + .rev() + .find(|attempt| attempt.status == ParentAttemptStatus::Running) + .map(|attempt| { + TimestampMs::new( + attempt + .started_at + .as_u64() + .saturating_add(attempt.timeout_ms), + ) + }) + } + + fn cleanup_complete(&self) -> bool { + self.attempts.iter().all(|attempt| match attempt.status { + ParentAttemptStatus::Running => false, + ParentAttemptStatus::Passed + | ParentAttemptStatus::Failed + | ParentAttemptStatus::TimedOut | ParentAttemptStatus::Canceled | ParentAttemptStatus::Indeterminate => { attempt @@ -1258,8 +2338,8 @@ impl ParentIntegrationController { } if !allowed_transition(&self.state, &next) { return Err(ParentIntegrationError::InvalidTransition { - from: self.state.clone(), - to: next, + from: Box::new(self.state.clone()), + to: Box::new(next), }); } self.state_version = self.state_version.saturating_add(1); @@ -1321,11 +2401,30 @@ fn allowed_transition(from: &ParentIntegrationState, to: &ParentIntegrationState ) | (State::RefreshingRepositories, State::Integrating) | (State::Integrating, State::RefreshingRepositories) + | (State::Integrating, State::Fixing { .. }) + | (State::Fixing { .. }, State::AwaitingFixReview { .. }) + | (State::AwaitingFixReview { .. }, State::Fixing { .. }) + | ( + State::AwaitingFixReview { .. }, + State::AwaitingFixMerge { .. } + ) + | ( + State::AwaitingFixMerge { .. }, + State::AwaitingFixReview { .. } + ) + | (State::AwaitingFixMerge { .. }, State::Fixing { .. }) + | (State::AwaitingFixReview { .. }, State::RefreshingAfterFixes) + | (State::AwaitingFixMerge { .. }, State::RefreshingAfterFixes) + | (State::RefreshingAfterFixes, State::Integrating) | (State::Integrating, State::FinalVerification) | (State::FinalVerification, State::Integrating) | (State::FinalVerification, State::Finalizing) | (State::Finalizing, State::Completed) | (State::Blocked { .. }, State::RefreshingRepositories) + | (State::Blocked { .. }, State::Fixing { .. }) + | (State::Blocked { .. }, State::AwaitingFixReview { .. }) + | (State::Blocked { .. }, State::AwaitingFixMerge { .. }) + | (State::Blocked { .. }, State::RefreshingAfterFixes) | (_, State::Blocked { .. }) | (_, State::Failed { .. }) | (_, State::Canceled { .. }) @@ -1356,6 +2455,17 @@ pub fn parent_command_identity(command: &str) -> String { mod tests { use super::*; + fn repair_policy() -> ParentRepairPolicy { + ParentRepairPolicy { + review_profile: "required".to_owned(), + review_provider: "github".to_owned(), + review_policy_generation: "policy-1".to_owned(), + required_checks: true, + required_review: true, + merge_method: "squash".to_owned(), + } + } + fn controller() -> ParentIntegrationController { let mut controller = ParentIntegrationController::new(IssueId::new("parent").expect("parent id"), 7) @@ -1374,7 +2484,11 @@ mod tests { .expect("repository"), checkout_handle: format!("checkout-{repo}"), relative_path: PathBuf::from(format!("repositories/{repo}")), + target_branch: "develop".to_owned(), target_commit: format!("commit-{repo}"), + instruction_path: PathBuf::from("AGENTS.md"), + instruction_hash: format!("instruction-{repo}"), + repair_policy: repair_policy(), }), "targets:1", TimestampMs::new(2), @@ -1417,6 +2531,7 @@ mod tests { command: "cargo test".to_owned(), command_hash: parent_command_identity("cargo test"), root: "parent_root".to_owned(), + repair_repository_id: None, }; assert!( controller @@ -1558,44 +2673,234 @@ mod tests { .record_baseline_verified("targets:2", TimestampMs::new(6)) .is_err() ); - - let mut failed_without_teardown = controller(); - let failed = failed_without_teardown + + let mut failed_without_teardown = controller(); + let failed = failed_without_teardown + .start_attempt( + "integration", + "attempt:failed", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + failed_without_teardown + .finish_attempt( + &failed, + ParentAttemptStatus::Failed, + None, + ParentCleanupReceipt { + status: ParentCleanupStatus::Pending, + occurred_at: TimestampMs::new(4), + detail: Some("no runtime completion event".to_owned()), + }, + TimestampMs::new(4), + ) + .expect("failed attempt"); + failed_without_teardown + .prepare_retry(&failed, "failed", TimestampMs::new(5)) + .expect("retry intent"); + failed_without_teardown + .record_baseline_verified("targets:2", TimestampMs::new(6)) + .expect_err("failed command without teardown blocks baseline refresh"); + + let mut stopped_by_harness = controller(); + let stopped = stopped_by_harness + .start_attempt( + "integration", + "attempt:stopped", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + stopped_by_harness + .observe_command_started( + &stopped, + "command-running", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(4), + ) + .expect("command start"); + stopped_by_harness + .observe_harness_stopped( + &stopped, + "interrupt observed paused state", + TimestampMs::new(103), + ) + .expect("stopped-state cleanup"); + let cleanup = stopped_by_harness.attempts[0] + .cleanup + .clone() + .expect("cleanup receipt"); + stopped_by_harness + .finish_attempt( + &stopped, + ParentAttemptStatus::TimedOut, + None, + cleanup, + TimestampMs::new(103), + ) + .expect("timed out attempt"); + stopped_by_harness + .prepare_retry(&stopped, "timeout", TimestampMs::new(104)) + .expect("retry intent"); + stopped_by_harness + .record_baseline_verified("targets:2", TimestampMs::new(105)) + .expect("stopped-state receipt permits baseline refresh"); + + let mut restarted = controller(); + let running = restarted + .start_attempt( + "integration", + "attempt:restart", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + restarted + .reconcile_restart(false, TimestampMs::new(4)) + .expect("restart reconciliation"); + assert!( + !restarted.can_cancel_without_harness(), + "a conversation-bound indeterminate attempt still needs stop reconciliation" + ); + assert_eq!( + restarted + .attempts + .iter() + .find(|attempt| attempt.id == running) + .expect("attempt") + .status, + ParentAttemptStatus::Indeterminate + ); + restarted + .record_baseline_verified("targets:2", TimestampMs::new(5)) + .expect_err("unknown teardown blocks baseline refresh"); + restarted + .attempts + .iter_mut() + .find(|attempt| attempt.id == running) + .expect("attempt") + .cleanup = Some(ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: TimestampMs::new(6), + detail: Some("recovery cleanup verified".to_owned()), + }); + assert!( + !restarted.can_cancel_without_harness(), + "resource cleanup alone cannot prove that a conversation-bound turn stopped" + ); + restarted + .observe_harness_stopped( + &running, + "recovery observed terminal harness state", + TimestampMs::new(7), + ) + .expect("terminal reconciliation"); + assert!(restarted.can_cancel_without_harness()); + restarted + .record_baseline_verified("targets:2", TimestampMs::new(8)) + .expect("explicit cleanup permits verified rerun"); + + let mut prelaunch = controller(); + let intent = prelaunch + .start_attempt_intent( + "integration", + "attempt:prelaunch", + ParentAttemptRoot::ParentRoot, + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("persisted launch intent"); + prelaunch + .reconcile_restart(false, TimestampMs::new(4)) + .expect("metadata-only intent reconciliation"); + let attempt = prelaunch + .attempts + .iter() + .find(|attempt| attempt.id == intent) + .expect("attempt"); + assert_eq!(attempt.status, ParentAttemptStatus::Indeterminate); + assert_eq!( + attempt.cleanup.as_ref().map(|cleanup| cleanup.status), + Some(ParentCleanupStatus::Succeeded) + ); + assert!( + prelaunch.can_cancel_without_harness(), + "a never-attached launch intent has conclusive no-harness evidence" + ); + prelaunch + .record_baseline_verified("targets:2", TimestampMs::new(5)) + .expect("metadata-only crash does not block the retry baseline"); + } + + #[test] + fn terminal_run_manifest_recovers_lost_controller_outcome_without_rotating_conversation() { + let mut controller = controller(); + let attempt_id = controller + .start_attempt( + "integration", + "attempt:terminal-before-outcome", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 100, + "targets:1", + TimestampMs::new(3), + ) + .expect("attempt"); + + controller + .reconcile_terminal_run_after_restart(TimestampMs::new(10)) + .expect("terminal run reconciliation"); + + let attempt = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .expect("attempt"); + assert_eq!(attempt.status, ParentAttemptStatus::Indeterminate); + assert_eq!( + attempt.cleanup.as_ref().map(|cleanup| cleanup.status), + Some(ParentCleanupStatus::Succeeded) + ); + assert_eq!( + controller.conversation_id.as_deref(), + Some("conversation-1") + ); + controller + .record_baseline_verified("targets:2", TimestampMs::new(11)) + .expect("terminal harness evidence permits baseline refresh"); + let next_attempt = controller .start_attempt( "integration", - "attempt:failed", + "attempt:after-terminal-recovery", ParentAttemptRoot::ParentRoot, "conversation-1", 100, - "targets:1", - TimestampMs::new(3), - ) - .expect("attempt"); - failed_without_teardown - .finish_attempt( - &failed, - ParentAttemptStatus::Failed, - None, - ParentCleanupReceipt { - status: ParentCleanupStatus::Pending, - occurred_at: TimestampMs::new(4), - detail: Some("no runtime completion event".to_owned()), - }, - TimestampMs::new(4), + "targets:2", + TimestampMs::new(12), ) - .expect("failed attempt"); - failed_without_teardown - .prepare_retry(&failed, "failed", TimestampMs::new(5)) - .expect("retry intent"); - failed_without_teardown - .record_baseline_verified("targets:2", TimestampMs::new(6)) - .expect_err("failed command without teardown blocks baseline refresh"); + .expect("retry stays on the authoritative conversation"); + assert_ne!(next_attempt, attempt_id); + } - let mut stopped_by_harness = controller(); - let stopped = stopped_by_harness + #[test] + fn verification_selects_real_completed_command_before_receipt_write() { + let mut controller = controller(); + let attempt = controller .start_attempt( "integration", - "attempt:stopped", + "attempt:commands", ParentAttemptRoot::ParentRoot, "conversation-1", 100, @@ -1603,606 +2908,1244 @@ mod tests { TimestampMs::new(3), ) .expect("attempt"); - stopped_by_harness + controller .observe_command_started( - &stopped, - "command-running", + &attempt, + "command-verify", "cargo test", ParentAttemptRoot::ParentRoot, TimestampMs::new(4), ) - .expect("command start"); - stopped_by_harness - .observe_harness_stopped( - &stopped, - "interrupt observed paused state", - TimestampMs::new(103), + .expect("verification start"); + controller + .observe_command_started( + &attempt, + "command-verify", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(4), ) - .expect("stopped-state cleanup"); - let cleanup = stopped_by_harness.attempts[0] - .cleanup - .clone() - .expect("cleanup receipt"); - stopped_by_harness - .finish_attempt( - &stopped, - ParentAttemptStatus::TimedOut, - None, - cleanup, - TimestampMs::new(103), + .expect("duplicate start is idempotent"); + controller + .observe_command_finished( + &attempt, + "command-verify", + 0, + Some("passed"), + TimestampMs::new(5), ) - .expect("timed out attempt"); - stopped_by_harness - .prepare_retry(&stopped, "timeout", TimestampMs::new(104)) - .expect("retry intent"); - stopped_by_harness - .record_baseline_verified("targets:2", TimestampMs::new(105)) - .expect("stopped-state receipt permits baseline refresh"); + .expect("verification finish"); + controller + .observe_command_finished( + &attempt, + "command-verify", + 0, + Some("passed"), + TimestampMs::new(5), + ) + .expect("duplicate finish is idempotent"); + controller + .observe_command_started( + &attempt, + "command-receipt", + "write final-verification.json", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(6), + ) + .expect("receipt write start"); + controller + .observe_command_finished(&attempt, "command-receipt", 0, None, TimestampMs::new(7)) + .expect("receipt write finish"); - let mut restarted = controller(); - let running = restarted + let evidence = ParentVerificationEvidence { + schema_version: 1, + run_id: "run-parent".to_owned(), + attempt: 1, + hierarchy_generation: controller.hierarchy_generation, + repository_commits: controller + .targets + .iter() + .map(|(repository_id, target)| { + (repository_id.clone(), target.target_commit.clone()) + }) + .collect(), + command: "cargo test".to_owned(), + command_hash: parent_command_identity("cargo test"), + root: "parent_root".to_owned(), + repair_repository_id: None, + }; + assert!( + controller + .record_verification_evidence(&attempt, &evidence) + .expect("select observed verification command") + ); + let attempt = controller + .attempts + .iter() + .find(|candidate| candidate.id == attempt) + .expect("attempt receipt"); + assert_eq!(attempt.commands.len(), 2); + assert_eq!(attempt.resources.len(), 4); + assert_eq!(attempt.exit_code, Some(0)); + } + + #[test] + fn verification_uses_exact_command_identity_while_persisting_redacted_text() { + let mut controller = controller(); + let attempt = controller .start_attempt( "integration", - "attempt:restart", + "attempt:exact-command", ParentAttemptRoot::ParentRoot, "conversation-1", 100, "targets:1", - TimestampMs::new(3), + TimestampMs::new(10), ) .expect("attempt"); - restarted - .reconcile_restart(false, TimestampMs::new(4)) - .expect("restart reconciliation"); - assert_eq!( - restarted - .attempts - .iter() - .find(|attempt| attempt.id == running) - .expect("attempt") - .status, - ParentAttemptStatus::Indeterminate - ); - restarted - .record_baseline_verified("targets:2", TimestampMs::new(5)) - .expect_err("unknown teardown blocks baseline refresh"); - restarted + let exact_command = "cargo test token=secret"; + controller + .observe_command_started( + &attempt, + "command-exact", + exact_command, + ParentAttemptRoot::ParentRoot, + TimestampMs::new(11), + ) + .expect("command start"); + controller + .observe_command_finished( + &attempt, + "command-exact", + 0, + Some("passed"), + TimestampMs::new(12), + ) + .expect("command finish"); + let receipt = controller .attempts - .iter_mut() - .find(|attempt| attempt.id == running) + .last() .expect("attempt") - .cleanup = Some(ParentCleanupReceipt { - status: ParentCleanupStatus::Succeeded, - occurred_at: TimestampMs::new(6), - detail: Some("recovery cleanup verified".to_owned()), - }); - restarted - .record_baseline_verified("targets:2", TimestampMs::new(7)) - .expect("explicit cleanup permits verified rerun"); + .commands + .last() + .expect("command"); + assert_ne!(receipt.command, exact_command); + assert_eq!(receipt.command_hash, parent_command_identity(exact_command)); - let mut prelaunch = controller(); - let intent = prelaunch - .start_attempt_intent( + let evidence = ParentVerificationEvidence { + schema_version: 1, + run_id: "run-parent".to_owned(), + attempt: 1, + hierarchy_generation: controller.hierarchy_generation, + repository_commits: controller + .targets + .iter() + .map(|(repository_id, target)| { + (repository_id.clone(), target.target_commit.clone()) + }) + .collect(), + command: redact_runtime_diagnostic(exact_command), + command_hash: parent_command_identity(exact_command), + root: "parent_root".to_owned(), + repair_repository_id: None, + }; + assert!( + controller + .record_verification_evidence(&attempt, &evidence) + .expect("exact command hash selects the observed command") + ); + } + + #[test] + fn command_events_before_the_current_attempt_are_ignored() { + let mut controller = controller(); + let attempt = controller + .start_attempt( "integration", - "attempt:prelaunch", + "attempt:current-turn", ParentAttemptRoot::ParentRoot, + "conversation-1", 100, "targets:1", - TimestampMs::new(3), + TimestampMs::new(10), + ) + .expect("attempt"); + controller + .observe_command_started( + &attempt, + "command-old-turn", + "cargo test", + ParentAttemptRoot::ParentRoot, + TimestampMs::new(9), + ) + .expect("stale start is ignored"); + controller + .observe_command_finished( + &attempt, + "command-old-turn", + 0, + Some("old completion"), + TimestampMs::new(9), ) - .expect("persisted launch intent"); - prelaunch - .reconcile_restart(false, TimestampMs::new(4)) - .expect("metadata-only intent reconciliation"); - let attempt = prelaunch - .attempts - .iter() - .find(|attempt| attempt.id == intent) - .expect("attempt"); - assert_eq!(attempt.status, ParentAttemptStatus::Indeterminate); - assert_eq!( - attempt.cleanup.as_ref().map(|cleanup| cleanup.status), - Some(ParentCleanupStatus::Succeeded) + .expect("stale completion is ignored"); + assert!( + controller + .attempts + .last() + .expect("attempt") + .commands + .is_empty() ); - prelaunch - .record_baseline_verified("targets:2", TimestampMs::new(5)) - .expect("metadata-only crash does not block the retry baseline"); } #[test] - fn terminal_run_manifest_recovers_lost_controller_outcome_without_rotating_conversation() { + fn resources_and_logs_are_bounded_and_attributable() { let mut controller = controller(); - let attempt_id = controller + let first = controller .start_attempt( "integration", - "attempt:terminal-before-outcome", + "attempt:1", ParentAttemptRoot::ParentRoot, "conversation-1", 100, "targets:1", TimestampMs::new(3), ) - .expect("attempt"); - + .expect("first"); controller - .reconcile_terminal_run_after_restart(TimestampMs::new(10)) - .expect("terminal run reconciliation"); - + .allocate_resource(&first, "port", "24001", TimestampMs::new(3)) + .expect("port"); + assert!(matches!( + controller.allocate_resource(&first, "port", "24001", TimestampMs::new(3)), + Err(ParentIntegrationError::ResourceCollision { .. }) + )); + controller + .append_log(&first, &"x".repeat(MAX_LOG_BYTES + 10)) + .expect("log"); let attempt = controller .attempts .iter() - .find(|attempt| attempt.id == attempt_id) + .find(|attempt| attempt.id == first) .expect("attempt"); - assert_eq!(attempt.status, ParentAttemptStatus::Indeterminate); - assert_eq!( - attempt.cleanup.as_ref().map(|cleanup| cleanup.status), - Some(ParentCleanupStatus::Succeeded) - ); - assert_eq!( - controller.conversation_id.as_deref(), - Some("conversation-1") + assert_eq!(attempt.bounded_log.len(), MAX_LOG_BYTES); + assert!(attempt.log_truncated); + + controller + .release_resource(&first, "port", "24001", true, None, TimestampMs::new(4)) + .expect("release port"); + assert!( + controller + .allocate_resource(&first, "port", "24001", TimestampMs::new(5)) + .is_ok() ); controller - .record_baseline_verified("targets:2", TimestampMs::new(11)) - .expect("terminal harness evidence permits baseline refresh"); - let next_attempt = controller - .start_attempt( - "integration", - "attempt:after-terminal-recovery", - ParentAttemptRoot::ParentRoot, - "conversation-1", - 100, - "targets:2", - TimestampMs::new(12), + .release_resource( + &first, + "port", + "24001", + false, + Some("still listening".to_owned()), + TimestampMs::new(6), ) - .expect("retry stays on the authoritative conversation"); - assert_ne!(next_attempt, attempt_id); + .expect("record failed cleanup"); + assert!(matches!( + controller.allocate_resource(&first, "port", "24001", TimestampMs::new(7)), + Err(ParentIntegrationError::ResourceCollision { .. }) + )); } #[test] - fn verification_selects_real_completed_command_before_receipt_write() { + fn attempt_ids_remain_monotonic_after_retention() { let mut controller = controller(); + controller.next_attempt_sequence = 64; let attempt = controller .start_attempt( "integration", - "attempt:commands", + "attempt:65", ParentAttemptRoot::ParentRoot, "conversation-1", - 100, + 1_000, "targets:1", TimestampMs::new(3), ) .expect("attempt"); + assert_eq!(attempt, "parent-attempt-65"); + assert_eq!(controller.next_attempt_sequence, 65); + } + + #[test] + fn admission_idempotency_survives_transition_history_compaction() { + let mut controller = controller(); + for sequence in 1..=(MAX_TRANSITIONS / 2 + 8) { + let attempt = controller + .start_attempt( + format!("integration {sequence}"), + format!("attempt:{sequence}"), + ParentAttemptRoot::ParentRoot, + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(sequence as u64 * 3), + ) + .expect("attempt"); + controller + .finish_attempt( + &attempt, + ParentAttemptStatus::Failed, + Some(1), + ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: TimestampMs::new(sequence as u64 * 3 + 1), + detail: None, + }, + TimestampMs::new(sequence as u64 * 3 + 1), + ) + .expect("finish attempt"); + controller + .prepare_retry(&attempt, "retry", TimestampMs::new(sequence as u64 * 3 + 1)) + .expect("prepare retry"); + controller + .record_baseline_verified("targets:1", TimestampMs::new(sequence as u64 * 3 + 2)) + .expect("baseline"); + } + assert_eq!(controller.transitions.len(), MAX_TRANSITIONS); + assert!( + !controller + .transitions + .iter() + .any(|transition| { transition.idempotency_key.ends_with(":children") }) + ); + + let mut restored: ParentIntegrationController = serde_json::from_value( + serde_json::to_value(&controller).expect("serialize controller"), + ) + .expect("restore controller"); + let state_version = restored.state_version; + restored + .admit("hierarchy:7", TimestampMs::new(10_000)) + .expect("persisted admission remains idempotent"); + assert_eq!(restored.state_version, state_version); + assert_eq!( + restored.admission_input_version.as_deref(), + Some("hierarchy:7") + ); + } + + #[test] + fn repository_neutral_parent_can_complete_with_no_checkout_targets() { + let mut controller = + ParentIntegrationController::new(IssueId::new("parent-empty").expect("parent id"), 1) + .expect("controller"); controller - .observe_command_started( - &attempt, - "command-verify", - "cargo test", - ParentAttemptRoot::ParentRoot, - TimestampMs::new(4), - ) - .expect("verification start"); - controller - .observe_command_started( - &attempt, - "command-verify", - "cargo test", - ParentAttemptRoot::ParentRoot, - TimestampMs::new(4), - ) - .expect("duplicate start is idempotent"); - controller - .observe_command_finished( - &attempt, - "command-verify", - 0, - Some("passed"), - TimestampMs::new(5), - ) - .expect("verification finish"); - controller - .observe_command_finished( - &attempt, - "command-verify", - 0, - Some("passed"), - TimestampMs::new(5), - ) - .expect("duplicate finish is idempotent"); + .admit("hierarchy:1", TimestampMs::new(1)) + .expect("admit"); controller - .observe_command_started( - &attempt, - "command-receipt", - "write final-verification.json", + .record_workspace_prepared([], "targets:empty", TimestampMs::new(2)) + .expect("empty parent workspace"); + let attempt = controller + .start_attempt( + "repository-neutral verification", + "attempt:empty", ParentAttemptRoot::ParentRoot, - TimestampMs::new(6), + "conversation-empty", + 100, + "targets:empty", + TimestampMs::new(3), ) - .expect("receipt write start"); + .expect("attempt"); + finish_passed(&mut controller, &attempt); controller - .observe_command_finished(&attempt, "command-receipt", 0, None, TimestampMs::new(7)) - .expect("receipt write finish"); + .complete(&attempt, "targets:empty", TimestampMs::new(5)) + .expect("empty target set is verified by the runtime command"); - let evidence = ParentVerificationEvidence { - schema_version: 1, - run_id: "run-parent".to_owned(), - attempt: 1, - hierarchy_generation: controller.hierarchy_generation, - repository_commits: controller - .targets - .iter() - .map(|(repository_id, target)| { - (repository_id.clone(), target.target_commit.clone()) - }) - .collect(), - command: "cargo test".to_owned(), - command_hash: parent_command_identity("cargo test"), - root: "parent_root".to_owned(), - }; + assert_eq!(controller.state, ParentIntegrationState::Completed); assert!( controller - .record_verification_evidence(&attempt, &evidence) - .expect("select observed verification command") - ); - let attempt = controller - .attempts - .iter() - .find(|candidate| candidate.id == attempt) - .expect("attempt receipt"); - assert_eq!(attempt.commands.len(), 2); - assert_eq!(attempt.resources.len(), 4); - assert_eq!(attempt.exit_code, Some(0)); + .final_evidence + .as_ref() + .expect("final evidence") + .repository_commits + .is_empty() + ); } #[test] - fn verification_uses_exact_command_identity_while_persisting_redacted_text() { + fn unchanged_targets_refresh_again_before_a_retry_attempt() { let mut controller = controller(); - let attempt = controller + let first = controller .start_attempt( "integration", - "attempt:exact-command", + "attempt:first", ParentAttemptRoot::ParentRoot, "conversation-1", - 100, + 1_000, "targets:1", - TimestampMs::new(10), + TimestampMs::new(3), ) - .expect("attempt"); - let exact_command = "cargo test token=secret"; + .expect("first attempt"); controller - .observe_command_started( - &attempt, - "command-exact", - exact_command, - ParentAttemptRoot::ParentRoot, - TimestampMs::new(11), + .finish_attempt( + &first, + ParentAttemptStatus::Failed, + Some(1), + ParentCleanupReceipt { + status: ParentCleanupStatus::Succeeded, + occurred_at: TimestampMs::new(4), + detail: None, + }, + TimestampMs::new(4), ) - .expect("command start"); + .expect("failed attempt"); controller - .observe_command_finished( - &attempt, - "command-exact", - 0, - Some("passed"), - TimestampMs::new(12), - ) - .expect("command finish"); - let receipt = controller - .attempts - .last() - .expect("attempt") - .commands - .last() - .expect("command"); - assert_ne!(receipt.command, exact_command); - assert_eq!(receipt.command_hash, parent_command_identity(exact_command)); + .prepare_retry(&first, "retry", TimestampMs::new(5)) + .expect("retry transition"); + controller + .record_baseline_verified("targets:1", TimestampMs::new(6)) + .expect("refresh unchanged targets"); - let evidence = ParentVerificationEvidence { - schema_version: 1, - run_id: "run-parent".to_owned(), - attempt: 1, - hierarchy_generation: controller.hierarchy_generation, - repository_commits: controller - .targets - .iter() - .map(|(repository_id, target)| { - (repository_id.clone(), target.target_commit.clone()) - }) - .collect(), - command: redact_runtime_diagnostic(exact_command), - command_hash: parent_command_identity(exact_command), - root: "parent_root".to_owned(), - }; assert!( controller - .record_verification_evidence(&attempt, &evidence) - .expect("exact command hash selects the observed command") + .start_attempt( + "integration retry", + "attempt:second", + ParentAttemptRoot::ParentRoot, + "conversation-1", + 1_000, + "targets:1", + TimestampMs::new(7), + ) + .is_ok() ); } #[test] - fn command_events_before_the_current_attempt_are_ignored() { + fn completed_intermediate_parent_keeps_checkout_evidence_for_ancestor() { let mut controller = controller(); let attempt = controller .start_attempt( "integration", - "attempt:current-turn", + "attempt:1", ParentAttemptRoot::ParentRoot, "conversation-1", - 100, + 30_000, "targets:1", - TimestampMs::new(10), + TimestampMs::new(3), ) .expect("attempt"); + finish_passed(&mut controller, &attempt); controller - .observe_command_started( - &attempt, - "command-old-turn", - "cargo test", - ParentAttemptRoot::ParentRoot, - TimestampMs::new(9), + .complete(&attempt, "targets:1", TimestampMs::new(5)) + .expect("complete"); + let encoded = serde_json::to_value(&controller).expect("encode"); + let recovered: ParentIntegrationController = + serde_json::from_value(encoded).expect("decode"); + assert_eq!(recovered.targets.len(), 3); + assert_eq!(recovered.final_evidence, controller.final_evidence); + } + + fn begin_repair(controller: &mut ParentIntegrationController) -> String { + controller + .begin_repair( + "repair:defect-a", + CanonicalRepositoryId::new("github:repository:a").expect("repository"), + "checkout-a", + "commit-a", + "targets:1", + TimestampMs::new(10), ) - .expect("stale start is ignored"); + .expect("begin repair") + } + + fn review_snapshot() -> ParentRepairProviderSnapshot { + ParentRepairProviderSnapshot { + pull_request_id: Some("42".to_owned()), + pull_request_url: Some("https://github.com/example/a/pull/42".to_owned()), + head_commit: Some("repair-commit-1".to_owned()), + review_head_commit: Some("repair-commit-1".to_owned()), + review_request_cursor: None, + open: true, + checks_passed: false, + checks_failed: false, + review_approved: false, + review_rejected: false, + changes_requested: false, + review_feedback: Vec::new(), + mergeable: true, + merge_conflict: false, + merged: false, + merge_result_commit: None, + target_contains_merge_result: false, + provider_available: true, + } + } + + fn authorize_repair_merge( + controller: &mut ParentIntegrationController, + repair_id: &str, + head_commit: &str, + occurred_at: u64, + ) { + let merge_input_version = format!("targets:1;repair-head:{head_commit}"); + let mut approved = review_snapshot(); + approved.head_commit = Some(head_commit.to_owned()); + approved.review_head_commit = Some(head_commit.to_owned()); + approved.checks_passed = true; + approved.review_approved = true; controller - .observe_command_finished( - &attempt, - "command-old-turn", - 0, - Some("old completion"), - TimestampMs::new(9), + .reconcile_repair_provider( + repair_id, + approved, + "targets:1", + TimestampMs::new(occurred_at), ) - .expect("stale completion is ignored"); + .expect("merge ready"); + let reconciliation = controller + .begin_provider_operation( + repair_id, + ParentProviderOperationKind::ReconcileMerge, + &merge_input_version, + TimestampMs::new(occurred_at + 1), + ) + .expect("merge reconciliation intent"); + controller + .record_provider_operation( + repair_id, + &reconciliation, + "open", + None, + TimestampMs::new(occurred_at + 2), + ) + .expect("merge reconciliation receipt"); + controller + .begin_provider_operation( + repair_id, + ParentProviderOperationKind::Merge, + &merge_input_version, + TimestampMs::new(occurred_at + 3), + ) + .expect("merge intent"); + } + + #[test] + fn repair_attempts_use_verified_target_policy_and_survive_recovery() { + let mut controller = controller(); + let repair_id = begin_repair(&mut controller); + let replay = begin_repair(&mut controller); + + assert_eq!(replay, repair_id); + let repair = controller.repair(&repair_id).expect("repair"); + assert_eq!(repair.branch, "fix/parent-g7-repair-1"); + assert_eq!(repair.instruction_hash, "instruction-a"); + assert_eq!(repair.policy, repair_policy()); + assert_eq!(repair.target_commit, "commit-a"); + + let recovered: ParentIntegrationController = serde_json::from_value( + serde_json::to_value(controller).expect("serialize repair controller"), + ) + .expect("recover repair controller"); + assert_eq!(recovered.repair_attempts.len(), 1); + assert_eq!(recovered.next_repair_sequence, 1); + } + + #[test] + fn schema_one_parent_targets_without_repair_policy_remain_readable() { + let controller = controller(); + let current = controller.targets.values().next().expect("target").clone(); + let mut value = serde_json::to_value(controller).expect("serialize controller"); + for target in value["targets"] + .as_object_mut() + .expect("target map") + .values_mut() + { + target + .as_object_mut() + .expect("target") + .remove("repair_policy"); + target + .as_object_mut() + .expect("target") + .remove("target_branch"); + target + .as_object_mut() + .expect("target") + .remove("instruction_path"); + target + .as_object_mut() + .expect("target") + .remove("instruction_hash"); + } + + let mut recovered: ParentIntegrationController = + serde_json::from_value(value).expect("schema-one controller remains readable"); + assert!(recovered.targets.values().all(|target| { + target.target_branch.is_empty() + && target.instruction_path.as_os_str().is_empty() + && target.instruction_hash.is_empty() + && target.repair_policy == ParentRepairPolicy::default() + })); assert!( - controller - .attempts - .last() - .expect("attempt") - .commands - .is_empty() + recovered + .migrate_legacy_repair_target(current.clone()) + .expect("verified provenance migrates") + ); + assert_eq!( + recovered.targets.get(¤t.repository_id), + Some(¤t) ); } #[test] - fn resources_and_logs_are_bounded_and_attributable() { + fn mutating_provider_operations_require_search_before_create() { let mut controller = controller(); - let first = controller - .start_attempt( - "integration", - "attempt:1", - ParentAttemptRoot::ParentRoot, - "conversation-1", - 100, + let repair_id = begin_repair(&mut controller); + + assert!(matches!( + controller.begin_provider_operation( + &repair_id, + ParentProviderOperationKind::CreateBranch, "targets:1", - TimestampMs::new(3), + TimestampMs::new(11), + ), + Err(ParentIntegrationError::ProviderReconciliationRequired { .. }) + )); + controller + .record_provider_operation( + &repair_id, + "repair:defect-a", + "missing", + None, + TimestampMs::new(11), ) - .expect("first"); + .expect("branch lookup receipt"); + let create_key = controller + .begin_provider_operation( + &repair_id, + ParentProviderOperationKind::CreateBranch, + "targets:1", + TimestampMs::new(12), + ) + .expect("create intent"); + assert_eq!( + controller + .begin_provider_operation( + &repair_id, + ParentProviderOperationKind::CreateBranch, + "targets:1", + TimestampMs::new(13), + ) + .expect("replayed create intent"), + create_key + ); + assert_eq!( + controller + .repair(&repair_id) + .expect("repair") + .operations + .len(), + 2 + ); + } + + #[test] + fn requested_changes_keep_one_attempt_and_reachable_merge_refreshes_target() { + let mut controller = controller(); + let repair_id = begin_repair(&mut controller); controller - .allocate_resource(&first, "port", "24001", TimestampMs::new(3)) - .expect("port"); - assert!(matches!( - controller.allocate_resource(&first, "port", "24001", TimestampMs::new(3)), - Err(ParentIntegrationError::ResourceCollision { .. }) - )); + .record_repair_branch_ready(&repair_id) + .expect("branch ready"); controller - .append_log(&first, &"x".repeat(MAX_LOG_BYTES + 10)) - .expect("log"); - let attempt = controller - .attempts - .iter() - .find(|attempt| attempt.id == first) - .expect("attempt"); - assert_eq!(attempt.bounded_log.len(), MAX_LOG_BYTES); - assert!(attempt.log_truncated); + .record_repair_push( + &repair_id, + "repair-commit-1", + "targets:1", + TimestampMs::new(11), + ) + .expect("push"); + controller + .record_repair_pull_request( + &repair_id, + "42", + "https://github.com/example/a/pull/42", + "targets:1", + TimestampMs::new(12), + ) + .expect("pull request"); + let mut changes = review_snapshot(); + changes.changes_requested = true; + changes.review_feedback = vec![ParentReviewFeedback { + thread_id: "thread-1".to_owned(), + body: "Keep the repair branch isolated.".to_owned(), + path: Some("src/repair.rs".to_owned()), + line: Some(42), + }]; controller - .release_resource(&first, "port", "24001", true, None, TimestampMs::new(4)) - .expect("release port"); - assert!( + .reconcile_repair_provider( + &repair_id, + changes.clone(), + "targets:1", + TimestampMs::new(13), + ) + .expect("requested changes"); + controller + .reconcile_repair_provider( + &repair_id, + changes.clone(), + "targets:1", + TimestampMs::new(14), + ) + .expect("repeated requested changes are idempotent before a push"); + assert_eq!( controller - .allocate_resource(&first, "port", "24001", TimestampMs::new(5)) - .is_ok() + .repair(&repair_id) + .expect("repair") + .requested_change_count, + 1 + ); + assert_eq!( + controller + .repair(&repair_id) + .expect("repair") + .review_feedback, + changes.review_feedback + ); + let recovered: ParentIntegrationController = serde_json::from_value( + serde_json::to_value(&controller).expect("persist repair feedback"), + ) + .expect("recover repair feedback"); + assert_eq!( + recovered + .repair(&repair_id) + .expect("recovered repair") + .review_feedback, + changes.review_feedback ); controller - .release_resource( - &first, - "port", - "24001", - false, - Some("still listening".to_owned()), - TimestampMs::new(6), + .record_repair_push( + &repair_id, + "repair-commit-2", + "targets:1", + TimestampMs::new(15), + ) + .expect("same PR repair push"); + controller + .reconcile_repair_provider(&repair_id, changes, "targets:1", TimestampMs::new(16)) + .expect("stale requested changes do not apply to the new head"); + + authorize_repair_merge(&mut controller, &repair_id, "repair-commit-2", 17); + let mut merged = review_snapshot(); + merged.head_commit = Some("repair-commit-2".to_owned()); + merged.review_head_commit = Some("repair-commit-2".to_owned()); + merged.checks_passed = true; + merged.review_approved = true; + merged.merged = true; + merged.open = false; + merged.merge_result_commit = Some("squash-result".to_owned()); + merged.target_contains_merge_result = true; + controller + .reconcile_repair_provider(&repair_id, merged, "targets:1", TimestampMs::new(18)) + .expect("merged"); + controller + .record_repair_refresh( + &repair_id, + "target-after-squash-result", + Path::new("AGENTS.md"), + "instruction-a-after", + "targets:2", + TimestampMs::new(19), + ) + .expect("refresh"); + + let repair = controller.repair(&repair_id).expect("repair"); + assert_eq!(controller.repair_attempts.len(), 1); + assert_eq!(repair.requested_change_count, 1); + assert_eq!(repair.pull_request_id.as_deref(), Some("42")); + assert_eq!(repair.status, ParentRepairStatus::Completed); + assert_eq!( + controller.targets[&repair.repository_id].target_commit, + "target-after-squash-result" + ); + assert_eq!(controller.state, ParentIntegrationState::Integrating); + } + + #[test] + fn disabled_review_and_check_gates_ignore_optional_provider_failures() { + let mut controller = controller(); + let repository_id = CanonicalRepositoryId::new("github:repository:a").expect("repository"); + let target = controller + .targets + .get_mut(&repository_id) + .expect("repair target"); + target.repair_policy.required_review = false; + target.repair_policy.required_checks = false; + let repair_id = begin_repair(&mut controller); + controller + .record_repair_push( + &repair_id, + "repair-commit-1", + "targets:1", + TimestampMs::new(11), + ) + .expect("push"); + controller + .record_repair_pull_request( + &repair_id, + "42", + "https://github.com/example/a/pull/42", + "targets:1", + TimestampMs::new(12), ) - .expect("record failed cleanup"); + .expect("pull request"); + + let mut optional_failures = review_snapshot(); + optional_failures.checks_failed = true; + optional_failures.review_rejected = true; + optional_failures.changes_requested = true; + optional_failures.review_feedback = vec![ParentReviewFeedback { + thread_id: "optional-thread".to_owned(), + body: "Optional feedback".to_owned(), + path: None, + line: None, + }]; + controller + .reconcile_repair_provider( + &repair_id, + optional_failures, + "targets:1", + TimestampMs::new(13), + ) + .expect("optional gates must not block the repair"); + + let repair = controller.repair(&repair_id).expect("repair"); + assert_eq!(repair.status, ParentRepairStatus::AwaitingMerge); + assert_eq!(repair.requested_change_count, 0); assert!(matches!( - controller.allocate_resource(&first, "port", "24001", TimestampMs::new(7)), - Err(ParentIntegrationError::ResourceCollision { .. }) + controller.state, + ParentIntegrationState::AwaitingFixMerge { .. } )); } #[test] - fn attempt_ids_remain_monotonic_after_retention() { + fn resolved_feedback_on_unchanged_head_can_satisfy_review_without_a_worker_turn() { let mut controller = controller(); - controller.next_attempt_sequence = 64; - let attempt = controller - .start_attempt( - "integration", - "attempt:65", - ParentAttemptRoot::ParentRoot, - "conversation-1", - 1_000, + let repair_id = begin_repair(&mut controller); + controller + .record_repair_push( + &repair_id, + "repair-commit-1", "targets:1", - TimestampMs::new(3), + TimestampMs::new(11), ) - .expect("attempt"); - assert_eq!(attempt, "parent-attempt-65"); - assert_eq!(controller.next_attempt_sequence, 65); + .expect("push"); + controller + .record_repair_pull_request( + &repair_id, + "42", + "https://github.com/example/a/pull/42", + "targets:1", + TimestampMs::new(12), + ) + .expect("pull request"); + let mut changes = review_snapshot(); + changes.changes_requested = true; + changes.review_feedback = vec![ParentReviewFeedback { + thread_id: "thread-pushback".to_owned(), + body: "Question the existing ownership boundary.".to_owned(), + path: None, + line: None, + }]; + controller + .reconcile_repair_provider(&repair_id, changes, "targets:1", TimestampMs::new(13)) + .expect("requested changes"); + + let mut resolved = review_snapshot(); + resolved.checks_passed = true; + resolved.review_approved = true; + controller + .reconcile_repair_provider(&repair_id, resolved, "targets:1", TimestampMs::new(14)) + .expect("resolved thread"); + + let repair = controller.repair(&repair_id).expect("repair"); + assert_eq!(repair.status, ParentRepairStatus::AwaitingMerge); + assert_eq!(repair.requested_change_count, 1); + assert!(repair.review_feedback.is_empty()); } #[test] - fn admission_idempotency_survives_transition_history_compaction() { - let mut controller = controller(); - for sequence in 1..=(MAX_TRANSITIONS / 2 + 8) { - let attempt = controller - .start_attempt( - format!("integration {sequence}"), - format!("attempt:{sequence}"), - ParentAttemptRoot::ParentRoot, - "conversation-1", - 1_000, + fn provider_failures_are_typed_and_resumable() { + for (mut snapshot, expected) in [ + { + let mut snapshot = review_snapshot(); + snapshot.provider_available = false; + (snapshot, ParentRepairStatus::ProviderUnavailable) + }, + { + let mut snapshot = review_snapshot(); + snapshot.open = false; + (snapshot, ParentRepairStatus::ExternallyClosed) + }, + { + let mut snapshot = review_snapshot(); + snapshot.head_commit = Some("foreign".to_owned()); + (snapshot, ParentRepairStatus::ForcePushed) + }, + { + let mut snapshot = review_snapshot(); + snapshot.merge_conflict = true; + (snapshot, ParentRepairStatus::MergeConflict) + }, + { + let mut snapshot = review_snapshot(); + snapshot.checks_failed = true; + (snapshot, ParentRepairStatus::FailedChecks) + }, + { + let mut snapshot = review_snapshot(); + snapshot.review_rejected = true; + (snapshot, ParentRepairStatus::ReviewRejected) + }, + ] { + let mut controller = controller(); + let repair_id = begin_repair(&mut controller); + controller + .record_repair_push( + &repair_id, + "repair-commit-1", "targets:1", - TimestampMs::new(sequence as u64 * 3), + TimestampMs::new(11), ) - .expect("attempt"); + .expect("push"); controller - .finish_attempt( - &attempt, - ParentAttemptStatus::Failed, - Some(1), - ParentCleanupReceipt { - status: ParentCleanupStatus::Succeeded, - occurred_at: TimestampMs::new(sequence as u64 * 3 + 1), - detail: None, - }, - TimestampMs::new(sequence as u64 * 3 + 1), + .record_repair_pull_request( + &repair_id, + "42", + "https://github.com/example/a/pull/42", + "targets:1", + TimestampMs::new(12), ) - .expect("finish attempt"); + .expect("pull request"); + snapshot.pull_request_id = Some("42".to_owned()); controller - .prepare_retry(&attempt, "retry", TimestampMs::new(sequence as u64 * 3 + 1)) - .expect("prepare retry"); - controller - .record_baseline_verified("targets:1", TimestampMs::new(sequence as u64 * 3 + 2)) - .expect("baseline"); + .reconcile_repair_provider(&repair_id, snapshot, "targets:1", TimestampMs::new(13)) + .expect("typed block"); + let status = controller.repair(&repair_id).expect("repair").status; + assert_eq!(status, expected); + assert!(status.resumable()); + assert!(matches!( + controller.state, + ParentIntegrationState::Blocked { .. } + )); } - assert_eq!(controller.transitions.len(), MAX_TRANSITIONS); - assert!( - !controller - .transitions - .iter() - .any(|transition| { transition.idempotency_key.ends_with(":children") }) - ); + } - let mut restored: ParentIntegrationController = serde_json::from_value( - serde_json::to_value(&controller).expect("serialize controller"), - ) - .expect("restore controller"); - let state_version = restored.state_version; - restored - .admit("hierarchy:7", TimestampMs::new(10_000)) - .expect("persisted admission remains idempotent"); - assert_eq!(restored.state_version, state_version); - assert_eq!( - restored.admission_input_version.as_deref(), - Some("hierarchy:7") - ); + #[test] + fn externally_merged_repair_without_orchestrator_intent_is_blocked() { + let mut controller = controller(); + let repair_id = begin_repair(&mut controller); + controller + .record_repair_branch_ready(&repair_id) + .expect("branch ready"); + controller + .record_repair_push( + &repair_id, + "repair-commit-1", + "targets:1", + TimestampMs::new(11), + ) + .expect("push"); + controller + .record_repair_pull_request( + &repair_id, + "42", + "https://github.com/example/a/pull/42", + "targets:1", + TimestampMs::new(12), + ) + .expect("pull request"); + let mut merged = review_snapshot(); + merged.open = false; + merged.merged = true; + merged.checks_passed = true; + merged.review_approved = true; + merged.merge_result_commit = Some("external-squash".to_owned()); + merged.target_contains_merge_result = true; + + controller + .reconcile_repair_provider(&repair_id, merged, "targets:1", TimestampMs::new(13)) + .expect("external merge is recorded as a resumable block"); + + let repair = controller.repair(&repair_id).expect("repair"); + assert_eq!(repair.status, ParentRepairStatus::ExternallyClosed); + assert!(repair.merge_result_commit.is_none()); + assert!(matches!( + controller.state, + ParentIntegrationState::Blocked { .. } + )); } #[test] - fn repository_neutral_parent_can_complete_with_no_checkout_targets() { - let mut controller = - ParentIntegrationController::new(IssueId::new("parent-empty").expect("parent id"), 1) - .expect("controller"); + fn merge_intent_for_superseded_repair_head_does_not_authorize_replacement() { + let mut controller = controller(); + let repair_id = begin_repair(&mut controller); controller - .admit("hierarchy:1", TimestampMs::new(1)) - .expect("admit"); + .record_repair_branch_ready(&repair_id) + .expect("branch ready"); controller - .record_workspace_prepared([], "targets:empty", TimestampMs::new(2)) - .expect("empty parent workspace"); - let attempt = controller - .start_attempt( - "repository-neutral verification", - "attempt:empty", - ParentAttemptRoot::ParentRoot, - "conversation-empty", - 100, - "targets:empty", - TimestampMs::new(3), + .record_repair_push( + &repair_id, + "repair-commit-1", + "targets:1", + TimestampMs::new(11), ) - .expect("attempt"); - finish_passed(&mut controller, &attempt); + .expect("first push"); controller - .complete(&attempt, "targets:empty", TimestampMs::new(5)) - .expect("empty target set is verified by the runtime command"); + .record_repair_pull_request( + &repair_id, + "42", + "https://github.com/example/a/pull/42", + "targets:1", + TimestampMs::new(12), + ) + .expect("pull request"); + authorize_repair_merge(&mut controller, &repair_id, "repair-commit-1", 13); - assert_eq!(controller.state, ParentIntegrationState::Completed); - assert!( - controller - .final_evidence + controller + .record_repair_push( + &repair_id, + "repair-commit-2", + "targets:1", + TimestampMs::new(17), + ) + .expect("replacement push"); + let stale_merge = controller + .repair(&repair_id) + .expect("repair") + .operations + .iter() + .find(|operation| operation.kind == ParentProviderOperationKind::Merge) + .expect("stale merge intent"); + assert_eq!( + stale_merge + .receipt .as_ref() - .expect("final evidence") - .repository_commits - .is_empty() + .map(|receipt| receipt.status.as_str()), + Some("superseded") + ); + + let mut merged = review_snapshot(); + merged.head_commit = Some("repair-commit-2".to_owned()); + merged.review_head_commit = Some("repair-commit-2".to_owned()); + merged.open = false; + merged.merged = true; + merged.checks_passed = true; + merged.review_approved = true; + merged.merge_result_commit = Some("external-squash".to_owned()); + merged.target_contains_merge_result = true; + controller + .reconcile_repair_provider(&repair_id, merged, "targets:1", TimestampMs::new(18)) + .expect("stale intent is rejected"); + + assert_eq!( + controller.repair(&repair_id).expect("repair").status, + ParentRepairStatus::ExternallyClosed ); } #[test] - fn unchanged_targets_refresh_again_before_a_retry_attempt() { + fn provider_outage_recovers_to_the_same_pull_request() { let mut controller = controller(); - let first = controller - .start_attempt( - "integration", - "attempt:first", - ParentAttemptRoot::ParentRoot, - "conversation-1", - 1_000, + let repair_id = begin_repair(&mut controller); + controller + .record_repair_push( + &repair_id, + "repair-commit-1", "targets:1", - TimestampMs::new(3), + TimestampMs::new(11), ) - .expect("first attempt"); + .expect("push"); controller - .finish_attempt( - &first, - ParentAttemptStatus::Failed, - Some(1), - ParentCleanupReceipt { - status: ParentCleanupStatus::Succeeded, - occurred_at: TimestampMs::new(4), - detail: None, - }, - TimestampMs::new(4), + .record_repair_pull_request( + &repair_id, + "42", + "https://github.com/example/a/pull/42", + "targets:1", + TimestampMs::new(12), ) - .expect("failed attempt"); + .expect("pull request"); + let mut unavailable = review_snapshot(); + unavailable.provider_available = false; controller - .prepare_retry(&first, "retry", TimestampMs::new(5)) - .expect("retry transition"); + .reconcile_repair_provider(&repair_id, unavailable, "targets:1", TimestampMs::new(13)) + .expect("outage"); controller - .record_baseline_verified("targets:1", TimestampMs::new(6)) - .expect("refresh unchanged targets"); + .reconcile_repair_provider( + &repair_id, + review_snapshot(), + "targets:1", + TimestampMs::new(14), + ) + .expect("provider recovery"); + assert_eq!( + controller.repair(&repair_id).expect("repair").status, + ParentRepairStatus::AwaitingReview + ); + assert!(matches!( + controller.state, + ParentIntegrationState::AwaitingFixReview { + ref pull_request_id, + .. + } if pull_request_id == "42" + )); + } - assert!( + #[test] + fn provider_outage_before_pull_request_recovers_to_publication() { + let mut controller = controller(); + let repair_id = begin_repair(&mut controller); + controller + .record_repair_push( + &repair_id, + "repair-commit-1", + "targets:1", + TimestampMs::new(11), + ) + .expect("push"); + let mut unavailable = review_snapshot(); + unavailable.provider_available = false; + unavailable.pull_request_id = None; + unavailable.pull_request_url = None; + unavailable.open = false; + controller + .reconcile_repair_provider(&repair_id, unavailable, "targets:1", TimestampMs::new(12)) + .expect("outage"); + let mut recovered = review_snapshot(); + recovered.pull_request_id = None; + recovered.pull_request_url = None; + recovered.open = false; + controller + .reconcile_repair_provider(&repair_id, recovered, "targets:1", TimestampMs::new(13)) + .expect("provider recovery"); + assert_eq!( + controller.repair(&repair_id).expect("repair").status, + ParentRepairStatus::AwaitingPullRequest + ); + assert!(matches!( + controller.state, + ParentIntegrationState::Fixing { .. } + )); + } + + #[test] + fn restart_during_repair_implementation_stays_in_fixing() { + for terminal_manifest in [false, true] { + let mut controller = controller(); + let repair_id = begin_repair(&mut controller); controller - .start_attempt( - "integration retry", - "attempt:second", - ParentAttemptRoot::ParentRoot, - "conversation-1", + .record_repair_branch_ready(&repair_id) + .expect("repair branch"); + let attempt_id = controller + .start_attempt_intent( + "repair implementation", + format!("repair-turn-{terminal_manifest}"), + ParentAttemptRoot::CheckoutHandle("checkout-a".to_owned()), 1_000, "targets:1", - TimestampMs::new(7), + TimestampMs::new(11), ) - .is_ok() - ); + .expect("repair attempt"); + + if terminal_manifest { + controller + .reconcile_terminal_run_after_restart(TimestampMs::new(12)) + .expect("terminal manifest recovery"); + } else { + controller + .reconcile_restart(false, TimestampMs::new(12)) + .expect("missing harness recovery"); + } + + assert!(matches!( + controller.state, + ParentIntegrationState::Fixing { .. } + )); + assert_eq!( + controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .map(|attempt| attempt.status), + Some(ParentAttemptStatus::Indeterminate) + ); + assert_eq!( + controller.repair(&repair_id).expect("repair").status, + ParentRepairStatus::Implementing + ); + } } #[test] - fn completed_intermediate_parent_keeps_checkout_evidence_for_ancestor() { + fn repairs_across_repositories_preserve_prior_attempt_evidence() { let mut controller = controller(); - let attempt = controller - .start_attempt( - "integration", - "attempt:1", - ParentAttemptRoot::ParentRoot, - "conversation-1", - 30_000, + let first_id = begin_repair(&mut controller); + controller + .record_repair_push(&first_id, "repair-a", "targets:1", TimestampMs::new(11)) + .expect("push a"); + controller + .record_repair_pull_request( + &first_id, + "42", + "https://github.com/example/a/pull/42", "targets:1", - TimestampMs::new(3), + TimestampMs::new(12), ) - .expect("attempt"); - finish_passed(&mut controller, &attempt); + .expect("pr a"); + authorize_repair_merge(&mut controller, &first_id, "repair-a", 13); + let mut merged = review_snapshot(); + merged.head_commit = Some("repair-a".to_owned()); + merged.review_head_commit = Some("repair-a".to_owned()); + merged.checks_passed = true; + merged.review_approved = true; + merged.open = false; + merged.merged = true; + merged.merge_result_commit = Some("squash-a".to_owned()); + merged.target_contains_merge_result = true; controller - .complete(&attempt, "targets:1", TimestampMs::new(5)) - .expect("complete"); - let encoded = serde_json::to_value(&controller).expect("encode"); - let recovered: ParentIntegrationController = - serde_json::from_value(encoded).expect("decode"); - assert_eq!(recovered.targets.len(), 3); - assert_eq!(recovered.final_evidence, controller.final_evidence); + .reconcile_repair_provider(&first_id, merged, "targets:1", TimestampMs::new(17)) + .expect("merge a"); + controller + .record_repair_refresh( + &first_id, + "squash-a", + Path::new("AGENTS.md"), + "instruction-a-2", + "targets:2", + TimestampMs::new(18), + ) + .expect("refresh a"); + + let second_id = controller + .begin_repair( + "repair:defect-b", + CanonicalRepositoryId::new("github:repository:b").expect("repository b"), + "checkout-b", + "commit-b", + "targets:2", + TimestampMs::new(19), + ) + .expect("begin b"); + assert_ne!(first_id, second_id); + assert_eq!(controller.repair_attempts.len(), 2); + assert_eq!( + controller.repair_attempts[0].status, + ParentRepairStatus::Completed + ); + assert_eq!( + controller.repair_attempts[0].pull_request_id.as_deref(), + Some("42") + ); + assert_eq!( + controller.repair_attempts[1].repository_id.as_str(), + "github:repository:b" + ); } } diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 450439cf5..caa0e49cb 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -36,7 +36,8 @@ use super::{ DurableOrchestratorState, HierarchyBlockedReason, HierarchySnapshot, LeaseRecord, LeaseResource, ParentAttemptRoot, ParentAttemptStatus, ParentCleanupReceipt, ParentCleanupStatus, ParentEligibilityEvidence, ParentIntegrationController, - ParentIntegrationError, ParentRepositoryTarget, + ParentIntegrationError, ParentProviderOperationKind, ParentRepairAttempt, ParentRepairPolicy, + ParentRepairStatus, ParentRepositoryTarget, }; const DISABLED_STALL_TIMEOUT_MS: u64 = u64::MAX / 4; @@ -237,6 +238,9 @@ pub struct WorkerStartRequest { /// A parent retry must attach this durable controller-owned conversation. /// The backend rejects a missing or different manifest before session launch. pub expected_parent_conversation_id: Option, + /// Active requested-change repair resumed in the existing parent + /// conversation. Provider receipts remain scheduler-owned. + pub parent_repair: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -387,6 +391,33 @@ pub trait TrackerBackend { hierarchy, )) } + async fn parent_repair_snapshot( + &mut self, + _repair: &ParentRepairAttempt, + ) -> Result, Self::Error> { + Ok(None) + } + async fn ensure_parent_repair_pull_request( + &mut self, + _repair: &ParentRepairAttempt, + ) -> Result, Self::Error> { + Ok(None) + } + async fn merge_parent_repair( + &mut self, + _repair: &ParentRepairAttempt, + ) -> Result, Self::Error> { + Ok(None) + } + async fn request_parent_repair_review( + &mut self, + _repair: &ParentRepairAttempt, + ) -> Result, Self::Error> { + Ok(None) + } + fn parent_repair_review_budget_exhausted(&self, _repair: &ParentRepairAttempt) -> bool { + false + } fn error_category(_error: &Self::Error) -> Option { None } @@ -447,6 +478,56 @@ pub trait WorkspaceBackend { Ok(Vec::new()) } + async fn reconcile_parent_repair_branch( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &ParentRepositoryTarget, + _repair: &ParentRepairAttempt, + ) -> Result, String)>, Self::Error> { + Ok(None) + } + + async fn prepare_parent_repair( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &ParentRepositoryTarget, + _repair: &ParentRepairAttempt, + ) -> Result, Self::Error> { + Ok(None) + } + + async fn reconcile_parent_repair_push( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &ParentRepositoryTarget, + _repair: &ParentRepairAttempt, + ) -> Result, bool)>, Self::Error> { + Ok(None) + } + + async fn publish_parent_repair( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &ParentRepositoryTarget, + _repair: &ParentRepairAttempt, + ) -> Result, Self::Error> { + Ok(None) + } + + async fn refresh_parent_repair( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &ParentRepositoryTarget, + _repair: &ParentRepairAttempt, + ) -> Result, Self::Error> { + Ok(None) + } + async fn recover_retry_exhaustion( &mut self, ) -> Result, Self::Error> { @@ -597,6 +678,8 @@ pub enum SchedulerError { Identifier(#[from] IdentifierError), #[error(transparent)] ParentIntegration(#[from] ParentIntegrationError), + #[error("parent repair operation is unavailable: {detail}")] + ParentRepairUnavailable { detail: String }, } pub struct Scheduler { @@ -681,32 +764,1270 @@ where } } - pub fn config(&self) -> &SchedulerConfig { - &self.config + pub fn config(&self) -> &SchedulerConfig { + &self.config + } + + pub fn tracker(&self) -> &T { + &self.tracker + } + + pub fn tracker_mut(&mut self) -> &mut T { + &mut self.tracker + } + + pub fn workspace(&self) -> &W { + &self.workspace + } + + pub fn workspace_mut(&mut self) -> &mut W { + &mut self.workspace + } + + pub fn worker(&self) -> &M { + &self.worker + } + + pub fn worker_mut(&mut self) -> &mut M { + &mut self.worker + } + + pub async fn begin_parent_repair( + &mut self, + parent_id: &IssueId, + repository_id: &crate::opensymphony_domain::CanonicalRepositoryId, + defect_key: &str, + observed_at: TimestampMs, + ) -> Result { + self.load_recovery_state().await?; + let (parent, workspace) = self + .executions + .get(parent_id) + .and_then(|execution| { + execution + .workspace() + .cloned() + .map(|workspace| (execution.issue().clone(), workspace)) + }) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no active workspace"), + })?; + let legacy_target = self + .hierarchy_state + .parent_integrations + .get(parent_id) + .and_then(|controller| controller.targets.get(repository_id)) + .is_some_and(|target| { + target.target_branch.is_empty() + && target.repair_policy == ParentRepairPolicy::default() + }); + if legacy_target { + let current_target = self + .workspace + .parent_workspace_targets(&parent, &workspace) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + .into_iter() + .find(|target| target.repository_id == *repository_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no migrated target {repository_id}"), + })?; + let previous = self.hierarchy_state.clone(); + self.hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked") + .migrate_legacy_repair_target(current_target)?; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + } + let (target, input_version) = { + let controller = self + .hierarchy_state + .parent_integrations + .get(parent_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no integration controller"), + })?; + ( + controller + .targets + .get(repository_id) + .cloned() + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no target {repository_id}"), + })?, + parent_controller_input_version(controller), + ) + }; + let previous = self.hierarchy_state.clone(); + let resource = self + .hierarchy_state + .descendant_resources_for(parent_id) + .into_iter() + .find(|resource| resource.repository_id == *repository_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no leased resource for {repository_id}"), + })?; + let repair_id = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked") + .begin_repair( + defect_key, + repository_id.clone(), + &target.checkout_handle, + &target.target_commit, + &input_version, + observed_at, + )?; + if let Err(error) = self.hierarchy_state.acquire_leases(vec![LeaseRecord { + kind: super::LeaseKind::Repair, + resource, + owner: super::LeaseOwner::repair(parent_id), + hierarchy_generation: self + .hierarchy_state + .parent_integrations + .get(parent_id) + .expect("controller was checked") + .hierarchy_generation, + acquired_at: observed_at.as_u64(), + expires_at: None, + released_at: None, + }]) { + self.hierarchy_state = previous; + return Err(SchedulerError::Workspace { + detail: error.to_string(), + }); + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + + let repair = self.repair(parent_id, &repair_id)?.clone(); + if repair.status != ParentRepairStatus::PreparingBranch { + return Ok(repair_id); + } + if !repair.operations.iter().any(|operation| { + operation.kind == ParentProviderOperationKind::ReconcileBranch + && operation.receipt.is_none() + }) { + self.persist_repair_intent( + parent_id, + &repair_id, + ParentProviderOperationKind::ReconcileBranch, + &input_version, + observed_at, + ) + .await?; + } + let (branch_head, instruction_hash) = self + .workspace + .reconcile_parent_repair_branch(&parent, &workspace, &target, &repair) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "workspace backend does not support parent repairs".to_owned(), + })?; + if instruction_hash != repair.instruction_hash { + return Err(SchedulerError::ParentRepairUnavailable { + detail: format!( + "repository instructions changed before repair: expected {}, got {}", + repair.instruction_hash, instruction_hash + ), + }); + } + self.complete_repair_operation( + parent_id, + &repair_id, + ParentProviderOperationKind::ReconcileBranch, + if branch_head.is_some() { + "found" + } else { + "missing" + }, + observed_at, + ) + .await?; + if let Some(branch_head) = branch_head { + if branch_head != repair.target_commit + && repair.pushed_commit.as_deref() != Some(&branch_head) + { + return Err(SchedulerError::ParentRepairUnavailable { + detail: format!( + "repair branch {} points at an unrecorded commit", + repair.branch + ), + }); + } + if repair.operations.iter().any(|operation| { + operation.kind == ParentProviderOperationKind::CreateBranch + && operation.receipt.is_none() + }) { + self.complete_repair_operation( + parent_id, + &repair_id, + ParentProviderOperationKind::CreateBranch, + "reconciled", + observed_at, + ) + .await?; + } + } else { + self.persist_repair_intent( + parent_id, + &repair_id, + ParentProviderOperationKind::CreateBranch, + &input_version, + observed_at, + ) + .await?; + let created_instruction_hash = self + .workspace + .prepare_parent_repair(&parent, &workspace, &target, &repair) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "workspace backend does not support branch creation".to_owned(), + })?; + if created_instruction_hash != repair.instruction_hash { + return Err(SchedulerError::ParentRepairUnavailable { + detail: "repository instructions changed during branch creation".to_owned(), + }); + } + self.complete_repair_operation( + parent_id, + &repair_id, + ParentProviderOperationKind::CreateBranch, + "created", + observed_at, + ) + .await?; + } + let previous = self.hierarchy_state.clone(); + let controller = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked"); + controller.record_repair_branch_ready(&repair_id)?; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + Ok(repair_id) + } + + pub async fn publish_parent_repair( + &mut self, + parent_id: &IssueId, + repair_id: &str, + observed_at: TimestampMs, + ) -> Result { + let (parent, workspace) = self.parent_context(parent_id)?; + let (target, repair, input_version) = self.repair_context(parent_id, repair_id)?; + let publication_input_version = format!( + "{input_version};repair-cycle:{}", + repair.requested_change_count + ); + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcilePush, + &publication_input_version, + observed_at, + ) + .await?; + let (local_commit, remote_commit, has_uncommitted_changes) = self + .workspace + .reconcile_parent_repair_push(&parent, &workspace, &target, &repair) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "workspace backend does not support repair push reconciliation".to_owned(), + })?; + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcilePush, + if remote_commit.is_some() { + "found" + } else { + "missing" + }, + observed_at, + ) + .await?; + if let Some(remote_commit) = remote_commit.as_deref() + && remote_commit != local_commit + && repair.pushed_commit.as_deref() != Some(remote_commit) + { + return Err(SchedulerError::ParentRepairUnavailable { + detail: "remote repair branch was force-pushed".to_owned(), + }); + } + if !has_uncommitted_changes && local_commit == repair.target_commit { + self.mark_parent_repair_implementation_required(parent_id, repair_id) + .await?; + return Err(SchedulerError::ParentRepairUnavailable { + detail: "repair branch has no commit beyond its recorded target".to_owned(), + }); + } + let publish_required = + has_uncommitted_changes || remote_commit.as_deref() != Some(local_commit.as_str()); + let commit = if !publish_required { + if repair.operations.iter().any(|operation| { + operation.kind == ParentProviderOperationKind::Push + && operation.input_version == publication_input_version + && operation.receipt.is_none() + }) { + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::Push, + "reconciled", + observed_at, + ) + .await?; + } + local_commit + } else { + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::Push, + &publication_input_version, + observed_at, + ) + .await?; + let commit = self + .workspace + .publish_parent_repair(&parent, &workspace, &target, &repair) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "workspace backend does not support repair publication".to_owned(), + })?; + if commit == repair.target_commit { + self.mark_parent_repair_implementation_required(parent_id, repair_id) + .await?; + return Err(SchedulerError::ParentRepairUnavailable { + detail: "repair branch has no commit beyond its recorded target".to_owned(), + }); + } + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::Push, + "pushed", + observed_at, + ) + .await?; + commit + }; + { + let previous = self.hierarchy_state.clone(); + self.hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked") + .record_repair_push(repair_id, &commit, &publication_input_version, observed_at)?; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + } + + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcilePullRequest, + &publication_input_version, + observed_at, + ) + .await?; + let repair = self.repair(parent_id, repair_id)?.clone(); + let snapshot = match self.tracker.parent_repair_snapshot(&repair).await { + Ok(snapshot) => snapshot, + Err(error) => { + self.set_linear_cooldown_from_tracker_error(&error, observed_at); + self.apply_parent_repair_snapshot( + parent_id, + repair_id, + unavailable_provider_snapshot(&repair), + &input_version, + observed_at, + ) + .await?; + return Err(SchedulerError::Tracker { + detail: error.to_string(), + }); + } + }; + let pull_request = match snapshot + .and_then(|snapshot| snapshot.pull_request_id.zip(snapshot.pull_request_url)) + { + Some(pull_request) => { + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcilePullRequest, + "found", + observed_at, + ) + .await?; + pull_request + } + None => { + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcilePullRequest, + "missing", + observed_at, + ) + .await?; + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::CreatePullRequest, + &publication_input_version, + observed_at, + ) + .await?; + self.tracker + .ensure_parent_repair_pull_request(&repair) + .await + .map_err(|error| self.tracker_operation_error(error, observed_at))? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "provider backend does not support repair pull requests".to_owned(), + })? + } + }; + let previous = self.hierarchy_state.clone(); + let controller = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked"); + controller.record_repair_pull_request( + repair_id, + &pull_request.0, + &pull_request.1, + &publication_input_version, + observed_at, + )?; + if controller + .repair(repair_id)? + .operations + .iter() + .any(|operation| { + operation.kind == ParentProviderOperationKind::CreatePullRequest + && operation.receipt.is_none() + }) + { + let key = controller + .repair(repair_id)? + .operations + .iter() + .find(|operation| { + operation.kind == ParentProviderOperationKind::CreatePullRequest + && operation.receipt.is_none() + }) + .expect("checked pending create operation") + .idempotency_key + .clone(); + controller.record_provider_operation( + repair_id, + &key, + "created", + Some(pull_request.0.clone()), + observed_at, + )?; + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + Ok(pull_request.1) + } + + pub async fn request_parent_repair_review( + &mut self, + parent_id: &IssueId, + repair_id: &str, + observed_at: TimestampMs, + ) -> Result { + let (_, repair, input_version) = self.repair_context(parent_id, repair_id)?; + let review_input_version = format!( + "{input_version};repair-head:{}", + repair.pushed_commit.as_deref().unwrap_or("missing") + ); + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcileReview, + &review_input_version, + observed_at, + ) + .await?; + let snapshot = self + .tracker + .parent_repair_snapshot(&repair) + .await + .map_err(|error| self.tracker_operation_error(error, observed_at))? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "provider backend does not support repair review reconciliation".to_owned(), + })?; + let already_reviewed = snapshot.review_head_commit.as_deref() + == repair.pushed_commit.as_deref() + && (snapshot.review_approved || snapshot.review_rejected || snapshot.changes_requested); + self.complete_repair_operation_with_detail( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcileReview, + if already_reviewed { "found" } else { "pending" }, + snapshot.review_request_cursor.clone(), + observed_at, + ) + .await?; + let snapshot = if already_reviewed { + snapshot + } else { + if self.tracker.parent_repair_review_budget_exhausted(&repair) { + let previous = self.hierarchy_state.clone(); + let controller = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked"); + controller.record_repair_review_budget_exhausted( + repair_id, + &review_input_version, + observed_at, + )?; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + return Ok(ParentRepairStatus::ReviewBudgetExhausted); + } + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::RequestReview, + &review_input_version, + observed_at, + ) + .await?; + let repair = self.repair(parent_id, repair_id)?.clone(); + let snapshot = self + .tracker + .request_parent_repair_review(&repair) + .await + .map_err(|error| self.tracker_operation_error(error, observed_at))? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "provider backend does not support repair review requests".to_owned(), + })?; + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::RequestReview, + "requested", + observed_at, + ) + .await?; + snapshot + }; + self.apply_parent_repair_snapshot( + parent_id, + repair_id, + snapshot, + &input_version, + observed_at, + ) + .await + } + + async fn advance_parent_repairs( + &mut self, + observed_at: TimestampMs, + freshly_inactive_issue_ids: &HashSet, + ) -> Result<(), SchedulerError> { + let repairs = self + .hierarchy_state + .parent_integrations + .iter() + .flat_map(|(parent_id, controller)| { + controller + .repair_attempts + .iter() + .filter(|repair| repair.status != ParentRepairStatus::Completed) + .map(move |repair| { + ( + parent_id.clone(), + repair.id.clone(), + repair.repository_id.clone(), + repair.status, + repair + .operations + .first() + .map(|operation| operation.idempotency_key.clone()) + .unwrap_or_default(), + repair.pushed_commit.clone(), + repair.operations.clone(), + ) + }) + }) + .collect::>(); + for (parent_id, repair_id, repository_id, status, defect_key, pushed, operations) in repairs + { + if self.linear_cooldown_active(observed_at) { + break; + } + if !self.parent_repair_advancement_allowed(&parent_id) { + continue; + } + if freshly_inactive_issue_ids.contains(&parent_id) { + continue; + } + let advancement = async { + match status { + ParentRepairStatus::PreparingBranch => { + self.begin_parent_repair( + &parent_id, + &repository_id, + &defect_key, + observed_at, + ) + .await?; + } + ParentRepairStatus::Implementing => { + let implementation_completed = self + .repair(&parent_id, &repair_id)? + .implementation_completed; + let execution_released = + self.executions.get(&parent_id).is_some_and(|execution| { + execution.status() == SchedulerStatus::Released + }); + if implementation_completed && execution_released { + self.publish_parent_repair(&parent_id, &repair_id, observed_at) + .await?; + } else if !implementation_completed { + self.queue_parent_repair_retry(&parent_id, observed_at) + .await?; + } + } + ParentRepairStatus::AwaitingPullRequest => { + self.publish_parent_repair(&parent_id, &repair_id, observed_at) + .await?; + } + ParentRepairStatus::AwaitingReview => { + let (_, _, input_version) = self.repair_context(&parent_id, &repair_id)?; + let review_input_version = format!( + "{input_version};repair-head:{}", + pushed.as_deref().unwrap_or("missing") + ); + let requested = operations.iter().any(|operation| { + operation.kind == ParentProviderOperationKind::RequestReview + && operation.input_version == review_input_version + && operation.receipt.is_some() + }); + if requested { + self.reconcile_parent_repair(&parent_id, &repair_id, observed_at) + .await?; + } else { + self.request_parent_repair_review(&parent_id, &repair_id, observed_at) + .await?; + } + } + ParentRepairStatus::AwaitingMerge => { + self.merge_parent_repair(&parent_id, &repair_id, observed_at) + .await?; + } + ParentRepairStatus::Refreshing => { + self.refresh_parent_repair(&parent_id, &repair_id, observed_at) + .await?; + } + ParentRepairStatus::ChangesRequested => { + self.queue_parent_repair_retry(&parent_id, observed_at) + .await?; + } + ParentRepairStatus::FailedChecks + | ParentRepairStatus::ReviewRejected + | ParentRepairStatus::ReviewBudgetExhausted + | ParentRepairStatus::ProviderUnavailable + | ParentRepairStatus::ExternallyClosed + | ParentRepairStatus::ForcePushed + | ParentRepairStatus::MergeConflict => { + self.reconcile_parent_repair(&parent_id, &repair_id, observed_at) + .await?; + } + ParentRepairStatus::Completed => {} + } + Ok::<(), SchedulerError>(()) + } + .await; + if let Err(error) = advancement { + let detail = redact_runtime_diagnostic(&error.to_string()); + warn!( + parent_id = %parent_id, + repair_id, + error = %detail, + "parent repair advancement failed; preserving the repair stage for retry" + ); + let previous = self.hierarchy_state.clone(); + if let Some(controller) = + self.hierarchy_state.parent_integrations.get_mut(&parent_id) + { + if let Err(record_error) = controller.record_repair_advancement_failure( + &repair_id, + &detail, + observed_at, + ) { + warn!( + parent_id = %parent_id, + repair_id, + error = %record_error, + "failed to record parent repair advancement failure" + ); + continue; + } + if let Err(persist_error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + self.hierarchy_state_dirty = true; + warn!( + parent_id = %parent_id, + repair_id, + error = %persist_error, + "failed to persist parent repair advancement failure" + ); + } + } + } + } + Ok(()) + } + + fn parent_repair_advancement_allowed(&self, parent_id: &IssueId) -> bool { + let Some(execution) = self.executions.get(parent_id) else { + return false; + }; + if execution.workspace().is_none() + || execution.issue().state.category != IssueStateCategory::Active + || matches!( + execution.state(), + crate::opensymphony_domain::SchedulerState::Released { + reason: ReleaseReason::TrackerInactive + | ReleaseReason::TrackerTerminal + | ReleaseReason::Cancelled + | ReleaseReason::RetryExhausted, + .. + } + ) + { + return false; + } + let Some(controller) = self.hierarchy_state.parent_integrations.get(parent_id) else { + return false; + }; + !controller.state.terminal() + && self + .hierarchy_state + .hierarchy + .get(parent_id) + .is_some_and(|snapshot| snapshot.accepts_event(controller.hierarchy_generation)) + } + + async fn queue_parent_repair_retry( + &mut self, + parent_id: &IssueId, + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let Some(execution) = self.remove_execution(parent_id) else { + return Ok(()); + }; + if execution.status() == SchedulerStatus::RetryQueued { + self.insert_execution(parent_id.clone(), execution); + return Ok(()); + } + if execution.status() != SchedulerStatus::Released { + self.insert_execution(parent_id.clone(), execution); + return Ok(()); + } + let previous_attempt = execution + .last_worker_outcome() + .and_then(|outcome| outcome.attempt); + let normal_retry_count = previous_attempt.map_or(0, RetryAttempt::get); + if self.retry_limit_reached(normal_retry_count) { + if let Err(error) = self + .persist_retry_exhaustion(execution.issue(), normal_retry_count) + .await + { + self.insert_execution(parent_id.clone(), execution); + return Err(error); + } + let exhausted = execution.clone().reopen(observed_at).and_then(|execution| { + execution.release(observed_at, ReleaseReason::RetryExhausted, None) + }); + let mut exhausted = match exhausted { + Ok(exhausted) => exhausted, + Err(error) => { + self.insert_execution(parent_id.clone(), execution); + return Err(error.into()); + } + }; + exhausted.set_retry_count_override(normal_retry_count); + self.insert_execution(parent_id.clone(), exhausted); + return Ok(()); + } + let next_execution = (|| -> Result { + let retry = RetryEntry::continuation( + execution.issue(), + previous_attempt, + normal_retry_count, + observed_at, + self.config.retry_policy, + )?; + Ok(execution + .clone() + .reopen(observed_at)? + .restore_retry(retry)?) + })(); + let next_execution = match next_execution { + Ok(execution) => execution, + Err(error) => { + self.insert_execution(parent_id.clone(), execution); + return Err(error); + } + }; + self.insert_execution(parent_id.clone(), next_execution); + self.persist_retry_if_queued(parent_id).await + } + + pub async fn reconcile_parent_repair( + &mut self, + parent_id: &IssueId, + repair_id: &str, + observed_at: TimestampMs, + ) -> Result { + let (_, repair, input_version) = self.repair_context(parent_id, repair_id)?; + let snapshot = match self.tracker.parent_repair_snapshot(&repair).await { + Ok(snapshot) => snapshot, + Err(error) => { + self.set_linear_cooldown_from_tracker_error(&error, observed_at); + return self + .apply_parent_repair_snapshot( + parent_id, + repair_id, + unavailable_provider_snapshot(&repair), + &input_version, + observed_at, + ) + .await; + } + } + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "provider backend does not support repair reconciliation".to_owned(), + })?; + let previous = self.hierarchy_state.clone(); + let controller = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked"); + controller.reconcile_repair_provider(repair_id, snapshot, &input_version, observed_at)?; + let status = controller.repair(repair_id)?.status; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + Ok(status) + } + + pub async fn merge_parent_repair( + &mut self, + parent_id: &IssueId, + repair_id: &str, + observed_at: TimestampMs, + ) -> Result { + let (_, repair, input_version) = self.repair_context(parent_id, repair_id)?; + if repair.status != ParentRepairStatus::AwaitingMerge { + return Err(SchedulerError::ParentRepairUnavailable { + detail: format!("repair {repair_id} has not satisfied central review policy"), + }); + } + let merge_input_version = format!( + "{input_version};repair-head:{}", + repair.pushed_commit.as_deref().unwrap_or("missing") + ); + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcileMerge, + &merge_input_version, + observed_at, + ) + .await?; + let snapshot = match self.tracker.parent_repair_snapshot(&repair).await { + Ok(snapshot) => snapshot, + Err(error) => { + self.set_linear_cooldown_from_tracker_error(&error, observed_at); + return self + .apply_parent_repair_snapshot( + parent_id, + repair_id, + unavailable_provider_snapshot(&repair), + &input_version, + observed_at, + ) + .await + .and(Err(SchedulerError::Tracker { + detail: error.to_string(), + })); + } + } + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "provider backend does not support repair merge reconciliation".to_owned(), + })?; + if snapshot.merged { + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcileMerge, + "already_merged", + observed_at, + ) + .await?; + return self + .apply_parent_repair_snapshot( + parent_id, + repair_id, + snapshot, + &input_version, + observed_at, + ) + .await; + } + self.complete_repair_operation( + parent_id, + repair_id, + ParentProviderOperationKind::ReconcileMerge, + "open", + observed_at, + ) + .await?; + let current_status = self + .apply_parent_repair_snapshot( + parent_id, + repair_id, + snapshot, + &input_version, + observed_at, + ) + .await?; + if current_status != ParentRepairStatus::AwaitingMerge { + return Ok(current_status); + } + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::Merge, + &merge_input_version, + observed_at, + ) + .await?; + let snapshot = self + .tracker + .merge_parent_repair(&repair) + .await + .map_err(|error| self.tracker_operation_error(error, observed_at))? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "provider backend does not support repair merge".to_owned(), + })?; + self.apply_parent_repair_snapshot( + parent_id, + repair_id, + snapshot, + &input_version, + observed_at, + ) + .await + } + + async fn mark_parent_repair_implementation_required( + &mut self, + parent_id: &IssueId, + repair_id: &str, + ) -> Result<(), SchedulerError> { + let previous = self.hierarchy_state.clone(); + self.hierarchy_state + .parent_integrations + .get_mut(parent_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no integration controller"), + })? + .record_repair_implementation_required(repair_id)?; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + Ok(()) + } + + pub async fn refresh_parent_repair( + &mut self, + parent_id: &IssueId, + repair_id: &str, + observed_at: TimestampMs, + ) -> Result { + let (parent, workspace) = self.parent_context(parent_id)?; + let (target, repair, input_version) = self.repair_context(parent_id, repair_id)?; + if repair.status != ParentRepairStatus::Refreshing { + return Err(SchedulerError::ParentRepairUnavailable { + detail: format!("repair {repair_id} has no merged target to refresh"), + }); + } + self.persist_repair_intent( + parent_id, + repair_id, + ParentProviderOperationKind::RefreshTarget, + &input_version, + observed_at, + ) + .await?; + let (refreshed, refreshed_instruction_path, refreshed_instruction_hash) = self + .workspace + .refresh_parent_repair(&parent, &workspace, &target, &repair) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: "workspace backend does not support post-repair refresh".to_owned(), + })?; + let previous = self.hierarchy_state.clone(); + let controller = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .expect("controller was checked"); + let key = controller + .repair(repair_id)? + .operations + .iter() + .find(|operation| { + operation.kind == ParentProviderOperationKind::RefreshTarget + && operation.receipt.is_none() + }) + .expect("refresh intent was persisted") + .idempotency_key + .clone(); + controller.record_provider_operation( + repair_id, + &key, + "refreshed", + Some(refreshed.clone()), + observed_at, + )?; + controller.record_repair_refresh( + repair_id, + &refreshed, + &refreshed_instruction_path, + &refreshed_instruction_hash, + &input_version, + observed_at, + )?; + if let Some(attempt_id) = controller + .attempts + .iter() + .rev() + .find(|attempt| attempt.status != ParentAttemptStatus::Running) + .map(|attempt| attempt.id.clone()) + { + controller.prepare_retry( + &attempt_id, + "merged parent repair refreshed; run final verification on the new target", + observed_at, + )?; + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + self.queue_parent_repair_retry(parent_id, observed_at) + .await?; + Ok(refreshed) + } + + fn parent_context( + &self, + parent_id: &IssueId, + ) -> Result<(NormalizedIssue, WorkspaceRecord), SchedulerError> { + let execution = self.executions.get(parent_id).ok_or_else(|| { + SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no active execution"), + } + })?; + let workspace = execution.workspace().cloned().ok_or_else(|| { + SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no active workspace"), + } + })?; + Ok((execution.issue().clone(), workspace)) } - pub fn tracker(&self) -> &T { - &self.tracker + fn repair( + &self, + parent_id: &IssueId, + repair_id: &str, + ) -> Result<&ParentRepairAttempt, SchedulerError> { + self.hierarchy_state + .parent_integrations + .get(parent_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no integration controller"), + })? + .repair(repair_id) + .map_err(Into::into) } - pub fn tracker_mut(&mut self) -> &mut T { - &mut self.tracker + fn repair_context( + &self, + parent_id: &IssueId, + repair_id: &str, + ) -> Result<(ParentRepositoryTarget, ParentRepairAttempt, String), SchedulerError> { + let controller = self + .hierarchy_state + .parent_integrations + .get(parent_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no integration controller"), + })?; + let repair = controller.repair(repair_id)?.clone(); + let target = controller + .targets + .get(&repair.repository_id) + .cloned() + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("repair {repair_id} target is unavailable"), + })?; + Ok((target, repair, parent_controller_input_version(controller))) } - pub fn workspace(&self) -> &W { - &self.workspace + async fn persist_repair_intent( + &mut self, + parent_id: &IssueId, + repair_id: &str, + kind: ParentProviderOperationKind, + input_version: &str, + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let previous = self.hierarchy_state.clone(); + self.hierarchy_state + .parent_integrations + .get_mut(parent_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no integration controller"), + })? + .begin_provider_operation(repair_id, kind, input_version, observed_at)?; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + Ok(()) } - pub fn workspace_mut(&mut self) -> &mut W { - &mut self.workspace + async fn complete_repair_operation( + &mut self, + parent_id: &IssueId, + repair_id: &str, + kind: ParentProviderOperationKind, + status: &str, + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + self.complete_repair_operation_with_detail( + parent_id, + repair_id, + kind, + status, + None, + observed_at, + ) + .await } - pub fn worker(&self) -> &M { - &self.worker + async fn complete_repair_operation_with_detail( + &mut self, + parent_id: &IssueId, + repair_id: &str, + kind: ParentProviderOperationKind, + status: &str, + detail: Option, + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let previous = self.hierarchy_state.clone(); + let controller = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no integration controller"), + })?; + let key = controller + .repair(repair_id)? + .operations + .iter() + .find(|operation| operation.kind == kind && operation.receipt.is_none()) + .map(|operation| operation.idempotency_key.clone()) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("repair {repair_id} has no pending {kind:?} intent"), + })?; + controller.record_provider_operation(repair_id, &key, status, detail, observed_at)?; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + Ok(()) } - pub fn worker_mut(&mut self) -> &mut M { - &mut self.worker + async fn apply_parent_repair_snapshot( + &mut self, + parent_id: &IssueId, + repair_id: &str, + snapshot: super::ParentRepairProviderSnapshot, + input_version: &str, + observed_at: TimestampMs, + ) -> Result { + let previous = self.hierarchy_state.clone(); + let controller = self + .hierarchy_state + .parent_integrations + .get_mut(parent_id) + .ok_or_else(|| SchedulerError::ParentRepairUnavailable { + detail: format!("parent {parent_id} has no integration controller"), + })?; + controller.reconcile_repair_provider(repair_id, snapshot, input_version, observed_at)?; + let status = controller.repair(repair_id)?.status; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous; + return Err(error); + } + Ok(status) } pub fn executions(&self) -> &BTreeMap { @@ -876,6 +2197,21 @@ where detail: error.to_string(), })?; self.apply_worker_updates(updates).await?; + if !self.linear_cooldown_active(observed_at) { + let freshly_inactive_issue_ids = pre_update_full_snapshot + .as_ref() + .map(|snapshot| { + self.hierarchy_state + .parent_integrations + .keys() + .filter(|parent_id| !snapshot.contains_active(parent_id.as_str())) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + self.advance_parent_repairs(observed_at, &freshly_inactive_issue_ids) + .await?; + } let mut dispatch_candidates = None; if let Some(tracker_snapshot) = pre_update_full_snapshot.as_ref() { @@ -1923,6 +3259,17 @@ where true } + fn tracker_operation_error( + &mut self, + error: T::Error, + observed_at: TimestampMs, + ) -> SchedulerError { + self.set_linear_cooldown_from_tracker_error(&error, observed_at); + SchedulerError::Tracker { + detail: error.to_string(), + } + } + async fn bootstrap_recovery( &mut self, tracker_snapshot: &TrackerSnapshot, @@ -3529,6 +4876,22 @@ where route: route.clone(), memory_grant_registry_recovered: self.recovered_memory_issue_ids.contains(issue_id), expected_parent_conversation_id, + parent_repair: self + .hierarchy_state + .parent_integrations + .get(issue_id) + .and_then(|controller| { + controller + .repair_attempts + .iter() + .rev() + .find(|repair| { + repair.status == ParentRepairStatus::ChangesRequested + || (repair.status == ParentRepairStatus::Implementing + && !repair.implementation_completed) + }) + .cloned() + }), }; self.remove_execution(issue_id); let launch = match self.worker.recover_worker(start_request).await { @@ -3991,7 +5354,17 @@ where continue; } - if !normalized.sub_issues.is_empty() { + let parent_repair_retry = self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .is_some_and(|controller| { + matches!( + controller.state, + super::ParentIntegrationState::Fixing { .. } + ) + }); + if !normalized.sub_issues.is_empty() && !parent_repair_retry { let parent_retry = self .executions .get(&issue_id) @@ -4061,7 +5434,7 @@ where } }; - if !normalized.sub_issues.is_empty() { + if !normalized.sub_issues.is_empty() && !parent_repair_retry { let targets = match self .workspace .parent_workspace_targets(&normalized, &workspace) @@ -4262,6 +5635,22 @@ where .and_then(|controller| controller.conversation_id.clone()) }) .flatten(), + parent_repair: self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .and_then(|controller| { + controller + .repair_attempts + .iter() + .rev() + .find(|repair| { + repair.status == ParentRepairStatus::ChangesRequested + || (repair.status == ParentRepairStatus::Implementing + && !repair.implementation_completed) + }) + .cloned() + }), }; if !normalized.sub_issues.is_empty() && !start_request.route.dry_run { @@ -4476,14 +5865,14 @@ where issue_id: &IssueId, execution: &IssueExecution, outcome: &mut WorkerOutcomeRecord, - ) -> Result<(), SchedulerError> { + ) -> Result { let previous_state = self.hierarchy_state.clone(); let merging_continuation = tracker_merging_interrupt_cancelled(execution, outcome); let Some(controller) = self.hierarchy_state.parent_integrations.get_mut(issue_id) else { - return Ok(()); + return Ok(false); }; let Some(attempt_id) = controller.current_attempt_id().map(str::to_owned) else { - return Ok(()); + return Ok(false); }; let launch_never_attached = controller .attempts @@ -4503,6 +5892,7 @@ where outcome.finished_at, )?; } + let mut verification_evidence_accepted = false; let verification_passed = if deadline_reached { controller.append_log( &attempt_id, @@ -4513,7 +5903,10 @@ where match outcome.parent_verification.as_ref() { Some(evidence) => { match controller.record_verification_evidence(&attempt_id, evidence) { - Ok(passed) => passed, + Ok(passed) => { + verification_evidence_accepted = true; + passed + } Err(error) => { controller.append_log( &attempt_id, @@ -4527,13 +5920,10 @@ where } }; enforce_parent_outcome_trust(outcome, deadline_reached, verification_passed); - if matches!( - outcome.outcome, - WorkerOutcomeKind::Succeeded | WorkerOutcomeKind::Failed | WorkerOutcomeKind::Cancelled - ) { + if outcome.harness_stopped { controller.observe_harness_stopped( &attempt_id, - "terminal worker outcome reconciled the harness turn as stopped", + "harness adapter observed a terminal runtime state", outcome.finished_at, )?; } @@ -4542,15 +5932,26 @@ where .iter() .find(|attempt| attempt.id == attempt_id) .and_then(|attempt| attempt.cleanup.clone()); - let status = match outcome.outcome { - WorkerOutcomeKind::Succeeded if verification_passed => ParentAttemptStatus::Passed, - WorkerOutcomeKind::Succeeded | WorkerOutcomeKind::Failed => ParentAttemptStatus::Failed, - WorkerOutcomeKind::TimedOut | WorkerOutcomeKind::Stalled => { - ParentAttemptStatus::TimedOut - } - WorkerOutcomeKind::Cancelled => ParentAttemptStatus::Canceled, - WorkerOutcomeKind::Detached | WorkerOutcomeKind::CancelFailed => { - ParentAttemptStatus::Indeterminate + let harness_stopped = controller + .attempts + .iter() + .find(|attempt| attempt.id == attempt_id) + .is_some_and(|attempt| attempt.harness_stopped_at.is_some()); + let status = if !launch_never_attached && !harness_stopped { + ParentAttemptStatus::Indeterminate + } else { + match outcome.outcome { + WorkerOutcomeKind::Succeeded if verification_passed => ParentAttemptStatus::Passed, + WorkerOutcomeKind::Succeeded | WorkerOutcomeKind::Failed => { + ParentAttemptStatus::Failed + } + WorkerOutcomeKind::TimedOut | WorkerOutcomeKind::Stalled => { + ParentAttemptStatus::TimedOut + } + WorkerOutcomeKind::Cancelled => ParentAttemptStatus::Canceled, + WorkerOutcomeKind::Detached | WorkerOutcomeKind::CancelFailed => { + ParentAttemptStatus::Indeterminate + } } }; let cleanup = observed_cleanup.unwrap_or_else(|| ParentCleanupReceipt { @@ -4572,8 +5973,50 @@ where .find(|attempt| attempt.id == attempt_id) .and_then(|attempt| attempt.exit_code); controller.finish_attempt(&attempt_id, status, exit_code, cleanup, outcome.finished_at)?; - let input_version = parent_controller_input_version(controller); + let repair_implementation_turn = matches!( + controller.state, + super::ParentIntegrationState::Fixing { .. } + ) && controller.repair_attempts.iter().any(|repair| { + repair.status == ParentRepairStatus::ChangesRequested + || (repair.status == ParentRepairStatus::Implementing + && !repair.implementation_completed) + }); + let inactive_repair_implementation = repair_implementation_turn + && execution.issue().state.category != IssueStateCategory::Active; if status == ParentAttemptStatus::Passed + && !inactive_repair_implementation + && matches!( + controller.state, + super::ParentIntegrationState::Fixing { .. } + ) + && let Some(repair_id) = controller + .repair_attempts + .iter() + .rev() + .find(|repair| { + repair.status == ParentRepairStatus::ChangesRequested + || (repair.status == ParentRepairStatus::Implementing + && !repair.implementation_completed) + }) + .map(|repair| repair.id.clone()) + { + controller.record_repair_implementation_completed(&repair_id)?; + } + let input_version = parent_controller_input_version(controller); + let repair_requested = verification_evidence_accepted + && status == ParentAttemptStatus::Failed + && outcome + .parent_verification + .as_ref() + .and_then(|evidence| evidence.repair_repository_id.as_ref()) + .is_some(); + if inactive_repair_implementation { + controller.cancel_without_harness( + "tracker became inactive before the repair implementation turn could be published", + &input_version, + outcome.finished_at, + )?; + } else if status == ParentAttemptStatus::Passed && execution.issue().state.category == IssueStateCategory::Terminal { controller.complete(&attempt_id, &input_version, outcome.finished_at)?; @@ -4588,10 +6031,15 @@ where "tracker merging superseded human-review polling; refresh before continuing", outcome.finished_at, )?; - } else if status == ParentAttemptStatus::Indeterminate - || (status == ParentAttemptStatus::Canceled - && (execution.issue().state.category != IssueStateCategory::Active - || acknowledged_operator_cancel_terminal(execution, outcome))) + } else if status == ParentAttemptStatus::Indeterminate { + controller.prepare_retry( + &attempt_id, + "parent harness outcome is indeterminate; retain ownership until terminal state is reconciled", + outcome.finished_at, + )?; + } else if status == ParentAttemptStatus::Canceled + && (execution.issue().state.category != IssueStateCategory::Active + || acknowledged_operator_cancel_terminal(execution, outcome)) { controller.cancel( outcome @@ -4602,6 +6050,14 @@ where status == ParentAttemptStatus::Canceled, outcome.finished_at, )?; + } else if repair_requested { + // The failed, harness-observed check selected a verified repository + // for repair. Keep the controller in Integrating so the + // orchestrator can atomically create the repair attempt below. + } else if repair_implementation_turn { + // Keep the durable repair in Fixing. The execution retry policy + // controls another implementation turn without publishing an + // incomplete branch. } else { controller.prepare_retry( &attempt_id, @@ -4618,7 +6074,7 @@ where self.hierarchy_state_dirty = true; return Err(error); } - Ok(()) + Ok(repair_requested) } async fn clear_parent_dispatch_intent_after_preparation_failure( @@ -5054,6 +6510,7 @@ where }; if execution.issue().state.category == IssueStateCategory::Terminal && !controller.state.terminal() + && matches!(controller.state, super::ParentIntegrationState::Integrating) && controller.current_attempt_id().is_none() && let Some(attempt_id) = controller .attempts @@ -5074,8 +6531,27 @@ where Err(error) => return Err(error.into()), } } - let Some(attempt_id) = controller.current_attempt_id().map(str::to_owned) else { + if controller.state.terminal() { return Ok(false); + } + let Some(attempt_id) = controller.current_attempt_id().map(str::to_owned) else { + if !controller.can_cancel_without_harness() { + // An indeterminate attempt or incomplete teardown can still + // own a live harness. Retain the controller and its leases + // for stop reconciliation or operator recovery. + return Ok(false); + } + // Provider-side repair stages deliberately have no live + // harness turn. A terminal tracker transition still owns the + // controller lifecycle, so finish it durably without issuing + // a fictitious worker interrupt. + let input_version = parent_controller_input_version(controller); + controller.cancel_without_harness( + format!("scheduler released parent integration: {reason:?}"), + &input_version, + observed_at, + )?; + return Ok(true); }; if execution .interrupt() @@ -5307,6 +6783,16 @@ where if let Some(reason) = non_active_release_reason(execution.issue().state.category.clone()) { self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) .await?; + if self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .is_some_and(ParentIntegrationController::has_unreconciled_harness) + { + return self + .queue_retry_for_outcome(execution, outcome, observed_at) + .await; + } return self .release_finished_execution(execution, observed_at, reason, Some(outcome)) .await; @@ -5322,6 +6808,16 @@ where ) { self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) .await?; + if self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .is_some_and(ParentIntegrationController::has_unreconciled_harness) + { + return self + .queue_retry_for_outcome(execution, outcome, observed_at) + .await; + } return self .release_finished_execution( execution, @@ -5356,14 +6852,82 @@ where { self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) .await?; + if self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .is_some_and(ParentIntegrationController::has_unreconciled_harness) + { + return self + .queue_retry_for_outcome(execution, outcome, observed_at) + .await; + } return self .release_finished_execution(execution, observed_at, reason, Some(outcome)) .await; } } - self.record_parent_worker_outcome(&issue_id, &execution, &mut outcome) + let repair_request_accepted = self + .record_parent_worker_outcome(&issue_id, &execution, &mut outcome) .await?; + let repair_implementation_ready = self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .is_some_and(|controller| { + matches!( + controller.state, + super::ParentIntegrationState::Fixing { .. } + ) && controller.repair_attempts.iter().any(|repair| { + repair.status == ParentRepairStatus::Implementing + && repair.implementation_completed + }) + }); + if repair_implementation_ready { + return self + .release_finished_execution( + execution, + observed_at, + ReleaseReason::Completed, + Some(outcome), + ) + .await; + } + let repair_repository_id = repair_request_accepted + .then(|| { + outcome + .parent_verification + .as_ref() + .and_then(|evidence| evidence.repair_repository_id.clone()) + }) + .flatten(); + if let Some(repository_id) = repair_repository_id { + let evidence = outcome + .parent_verification + .as_ref() + .expect("repair repository was selected from verification evidence"); + let defect_key = format!( + "parent-repair:{}:{}:{}:{}", + issue_id, evidence.run_id, evidence.attempt, repository_id + ); + self.insert_execution(issue_id.clone(), execution.clone()); + let repair_result = self + .begin_parent_repair(&issue_id, &repository_id, &defect_key, outcome.finished_at) + .await; + let execution = self + .remove_execution(&issue_id) + .expect("parent execution was temporarily restored"); + repair_result?; + return self + .release_finished_execution( + execution, + observed_at, + ReleaseReason::Completed, + Some(outcome), + ) + .await; + } if self.parent_waiting_for_tracker_confirmation(&issue_id) { return self .release_finished_execution( @@ -5375,6 +6939,17 @@ where .await; } + if self + .hierarchy_state + .parent_integrations + .get(&issue_id) + .is_some_and(ParentIntegrationController::has_unreconciled_harness) + { + return self + .queue_retry_for_outcome(execution, outcome, observed_at) + .await; + } + let retry_count = execution .current_run() .map(|run| run.normal_retry_count) @@ -5725,6 +7300,31 @@ where allow_retry: bool, reachable_child_edges: Option<&BTreeSet<(IssueId, IssueId)>>, ) -> Result { + if self + .hierarchy_state + .parent_integrations + .get(&normalized.id) + .is_some_and(ParentIntegrationController::has_unreconciled_harness) + { + return Ok(false); + } + if allow_retry + && self + .hierarchy_state + .parent_integrations + .get(&normalized.id) + .is_some_and(|controller| { + matches!( + controller.state, + super::ParentIntegrationState::Fixing { .. } + ) && controller + .repair_attempts + .iter() + .any(|repair| repair.status == ParentRepairStatus::ChangesRequested) + }) + { + return Ok(true); + } if self.linear_cooldown_active(observed_at) { return Ok(false); } @@ -7340,6 +8940,31 @@ fn parent_controller_input_version(controller: &ParentIntegrationController) -> ) } +fn unavailable_provider_snapshot( + repair: &ParentRepairAttempt, +) -> super::ParentRepairProviderSnapshot { + super::ParentRepairProviderSnapshot { + pull_request_id: repair.pull_request_id.clone(), + pull_request_url: repair.pull_request_url.clone(), + head_commit: repair.pushed_commit.clone(), + review_head_commit: None, + review_request_cursor: None, + open: false, + checks_passed: false, + checks_failed: false, + review_approved: false, + review_rejected: false, + changes_requested: false, + review_feedback: Vec::new(), + mergeable: false, + merge_conflict: false, + merged: false, + merge_result_commit: None, + target_contains_merge_result: false, + provider_available: false, + } +} + fn observe_parent_command_event( controller: &mut ParentIntegrationController, attempt_id: &str, @@ -7542,6 +9167,7 @@ fn enforce_parent_outcome_trust( #[cfg(test)] mod tests { use super::*; + use crate::opensymphony_orchestrator::ParentRepairPolicy; #[test] fn conversation_suffix_matches_gateway_alias_shape() { @@ -7560,6 +9186,7 @@ mod tests { turn_count: 1, summary: Some("claimed success".to_owned()), error: None, + harness_stopped: false, parent_verification: None, }; let mut late = outcome(); @@ -7665,7 +9292,18 @@ mod tests { .expect("repository id"), checkout_handle: "checkout-one".to_owned(), relative_path: PathBuf::from("repositories/one"), + target_branch: "develop".to_owned(), target_commit: "abc123".to_owned(), + instruction_path: PathBuf::from("AGENTS.md"), + instruction_hash: "instructions-1".to_owned(), + repair_policy: ParentRepairPolicy { + review_profile: "required".to_owned(), + review_provider: "github".to_owned(), + review_policy_generation: "policy-1".to_owned(), + required_checks: true, + required_review: true, + merge_method: "squash".to_owned(), + }, }], "targets:1", TimestampMs::new(2), @@ -7767,7 +9405,18 @@ mod tests { .expect("repository id"), checkout_handle: "checkout-one".to_owned(), relative_path: PathBuf::from("repositories/one"), + target_branch: "develop".to_owned(), target_commit: "abc123".to_owned(), + instruction_path: PathBuf::from("AGENTS.md"), + instruction_hash: "instructions-1".to_owned(), + repair_policy: ParentRepairPolicy { + review_profile: "required".to_owned(), + review_provider: "github".to_owned(), + review_policy_generation: "policy-1".to_owned(), + required_checks: true, + required_review: true, + merge_method: "squash".to_owned(), + }, }], "targets:1", TimestampMs::new(2), diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 163d3a830..cae7a6a25 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -13,14 +13,15 @@ use crate::opensymphony_domain::{ use crate::opensymphony_orchestrator::{ ChildEligibilityEvidence, ConversationId, ConversationMetadata, HierarchySnapshot, IssueId, IssueIdentifier, IssueRef, IssueState, IssueStateCategory, LeaseKind, LeaseOwner, LeaseRecord, - LeaseResource, NormalizedIssue, ParentEligibilityEvidence, RecoveredRun, RecoveryRecord, - ReleaseReason, RequiredMergeCommit, RetryAttempt, RetryEntry, RetryExhaustionRecord, - RetryPendingRecord, RetryReason, RuntimeStreamState, Scheduler, SchedulerConfig, - SchedulerStatus, TimestampMs, TrackerBackend, TrackerIssue, TrackerIssueBlocker, - TrackerIssueState, TrackerIssueStateKind, TrackerIssueStateSnapshot, TrackerIssueSummary, - WorkerAbortReason, WorkerBackend, WorkerId, WorkerInterruptAcknowledgement, WorkerLaunch, - WorkerOutcomeKind, WorkerOutcomeRecord, WorkerStartRequest, WorkerUpdate, WorkspaceBackend, - WorkspaceKey, WorkspaceRecord, decide_issue_route, + LeaseResource, NormalizedIssue, ParentEligibilityEvidence, ParentRepairPolicy, RecoveredRun, + RecoveryRecord, ReleaseReason, RequiredMergeCommit, RetryAttempt, RetryEntry, + RetryExhaustionRecord, RetryPendingRecord, RetryReason, RuntimeStreamState, Scheduler, + SchedulerConfig, SchedulerStatus, TimestampMs, TrackerBackend, TrackerIssue, + TrackerIssueBlocker, TrackerIssueState, TrackerIssueStateKind, TrackerIssueStateSnapshot, + TrackerIssueSummary, WorkerAbortReason, WorkerBackend, WorkerId, + WorkerInterruptAcknowledgement, WorkerLaunch, WorkerOutcomeKind, WorkerOutcomeRecord, + WorkerStartRequest, WorkerUpdate, WorkspaceBackend, WorkspaceKey, WorkspaceRecord, + decide_issue_route, }; use crate::opensymphony_workflow::RoutingConfig; use chrono::{TimeZone, Utc}; @@ -29,6 +30,17 @@ fn ts(value: u64) -> TimestampMs { TimestampMs::new(value) } +fn test_repair_policy() -> ParentRepairPolicy { + ParentRepairPolicy { + review_profile: "required".to_owned(), + review_provider: "github".to_owned(), + review_policy_generation: "policy-1".to_owned(), + required_checks: true, + required_review: true, + merge_method: "squash".to_owned(), + } +} + fn dt(value: u64) -> chrono::DateTime { Utc.timestamp_millis_opt(value as i64) .single() @@ -334,6 +346,7 @@ fn parent_verification_evidence( "cargo test-system-duckdb --test integration", ), root: "parent_root".to_owned(), + repair_repository_id: None, } } @@ -389,6 +402,14 @@ struct FakeTracker { parent_evidence: Option, parent_evidence_errors: VecDeque, parent_evidence_requests: usize, + repair_snapshots: VecDeque, + repair_snapshot_errors: VecDeque, + repair_snapshot_requests: usize, + repair_pull_request: Option<(String, String)>, + repair_pull_request_creates: usize, + repair_review_requests: usize, + repair_review_budget_exhausted: bool, + repair_merge_requests: usize, } impl TrackerBackend for FakeTracker { @@ -475,6 +496,51 @@ impl TrackerBackend for FakeTracker { })) } + async fn parent_repair_snapshot( + &mut self, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> + { + self.repair_snapshot_requests += 1; + if let Some(error) = self.repair_snapshot_errors.pop_front() { + return Err(error); + } + Ok(self.repair_snapshots.pop_front()) + } + + async fn ensure_parent_repair_pull_request( + &mut self, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + self.repair_pull_request_creates += 1; + Ok(self.repair_pull_request.clone()) + } + + async fn request_parent_repair_review( + &mut self, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> + { + self.repair_review_requests += 1; + Ok(self.repair_snapshots.pop_front()) + } + + fn parent_repair_review_budget_exhausted( + &self, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> bool { + self.repair_review_budget_exhausted + } + + async fn merge_parent_repair( + &mut self, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> + { + self.repair_merge_requests += 1; + Ok(self.repair_snapshots.pop_front()) + } + fn error_category(error: &Self::Error) -> Option { error.category } @@ -514,6 +580,20 @@ struct FakeWorkspace { persist_durable_state_results: VecDeque>, recovered_run_started_at: BTreeMap, parent_targets: Vec, + repair_branch_reconciliations: usize, + repair_branch_head: Option, + repair_instruction_hash: Option, + repair_branch_creates: usize, + repair_push_reconciliations: usize, + repair_remote_head: Option, + repair_has_uncommitted_changes: bool, + repair_pushes: usize, + repair_publish_results: VecDeque, FakeError>>, + repair_push_commit: Option, + repair_refreshes: usize, + repair_refresh_commit: Option, + repair_refresh_instruction_hash: Option, + repair_refresh_instruction_path: Option, } impl WorkspaceBackend for FakeWorkspace { @@ -553,6 +633,94 @@ impl WorkspaceBackend for FakeWorkspace { Ok(self.parent_targets.clone()) } + async fn reconcile_parent_repair_branch( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &crate::opensymphony_orchestrator::ParentRepositoryTarget, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, String)>, Self::Error> { + self.repair_branch_reconciliations += 1; + Ok(self + .repair_instruction_hash + .clone() + .map(|hash| (self.repair_branch_head.clone(), hash))) + } + + async fn prepare_parent_repair( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &crate::opensymphony_orchestrator::ParentRepositoryTarget, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + self.repair_branch_creates += 1; + self.repair_branch_head = Some(_repair.target_commit.clone()); + Ok(self.repair_instruction_hash.clone()) + } + + async fn reconcile_parent_repair_push( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &crate::opensymphony_orchestrator::ParentRepositoryTarget, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, bool)>, Self::Error> { + self.repair_push_reconciliations += 1; + let local = if self.repair_has_uncommitted_changes { + self.repair_branch_head.clone() + } else { + self.repair_push_commit + .clone() + .or_else(|| self.repair_branch_head.clone()) + }; + Ok(local.map(|local| { + ( + local, + self.repair_remote_head.clone(), + self.repair_has_uncommitted_changes, + ) + })) + } + + async fn publish_parent_repair( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &crate::opensymphony_orchestrator::ParentRepositoryTarget, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + self.repair_pushes += 1; + if let Some(result) = self.repair_publish_results.pop_front() { + return result; + } + self.repair_remote_head = self.repair_push_commit.clone(); + self.repair_branch_head = self.repair_push_commit.clone(); + self.repair_has_uncommitted_changes = false; + Ok(self.repair_push_commit.clone()) + } + + async fn refresh_parent_repair( + &mut self, + _parent: &NormalizedIssue, + _workspace: &WorkspaceRecord, + _target: &crate::opensymphony_orchestrator::ParentRepositoryTarget, + _repair: &crate::opensymphony_orchestrator::ParentRepairAttempt, + ) -> Result, Self::Error> { + self.repair_refreshes += 1; + Ok(self.repair_refresh_commit.clone().and_then(|commit| { + self.repair_refresh_instruction_hash.clone().map(|hash| { + ( + commit, + self.repair_refresh_instruction_path + .clone() + .unwrap_or_else(|| PathBuf::from("AGENTS.md")), + hash, + ) + }) + })) + } + async fn recovered_run_started_at( &mut self, ) -> Result, Self::Error> { @@ -706,10 +874,18 @@ impl WorkerBackend for FakeWorker { self.launches.push(request.clone()); match self.launch_results.pop_front() { Some(result) => result, - None => Ok(WorkerLaunch { - conversation: conversation(&request.run.worker_id), - started_at: None, - }), + None => { + let mut launched = conversation(&request.run.worker_id); + if let Some(expected) = request.expected_parent_conversation_id.as_deref() { + launched.conversation_id = ConversationId::new(expected.to_owned()) + .expect("expected parent conversation id is valid"); + launched.fresh_conversation = false; + } + Ok(WorkerLaunch { + conversation: launched, + started_at: None, + }) + } } } @@ -785,102 +961,1496 @@ async fn parent_provider_cooldown_stops_later_lookups_in_the_same_dispatch_pass( "prepared leaf launch must survive" ); scheduler - .tick(ts(200)) + .tick(ts(200)) + .await + .expect("cooldown tick should still run"); + assert_eq!(scheduler.tracker().parent_evidence_requests, 1); +} + +async fn launched_parent_scheduler( + suffix: &str, +) -> (Scheduler, IssueId) { + launched_parent_scheduler_with_config(suffix, scheduler_config()).await +} + +async fn launched_parent_scheduler_with_config( + suffix: &str, + config: SchedulerConfig, +) -> (Scheduler, IssueId) { + let parent_id = IssueId::new(format!("parent-{suffix}")).expect("parent id"); + let child_id = IssueId::new(format!("child-{suffix}")).expect("child id"); + let parent_identifier = format!("COE-PARENT-{suffix}"); + let child_identifier = format!("COE-CHILD-{suffix}"); + let mut parent = tracker_issue(parent_id.as_str(), &parent_identifier, "In Progress", 0); + parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: child_identifier, + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let snapshot = HierarchySnapshot::new(&parent); + let repository_id = + CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: repository_id.clone(), + checkout_generation: format!("checkout-{suffix}"), + }; + let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + hierarchy: BTreeMap::from([(parent_id.clone(), snapshot.clone())]), + leases: vec![LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(&child_id), + hierarchy_generation: snapshot.generation, + acquired_at: 1, + expires_at: None, + released_at: None, + }], + terminal_orchestrator_issues: [child_id.clone()].into_iter().collect(), + ..Default::default() + }; + let evidence = ParentEligibilityEvidence { + hierarchy_generation: snapshot.generation, + children: vec![ChildEligibilityEvidence { + child_id, + hierarchy_generation: snapshot.generation, + orchestrator_terminal: true, + provider_merge_confirmed: true, + merge_required: true, + merge_result_commit: Some(format!("commit-{suffix}")), + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: vec![RequiredMergeCommit { + repository_id: Some(repository_id.clone()), + commit: format!("commit-{suffix}"), + }], + merge_repository_id: Some(repository_id.clone()), + merge_repository_ids: vec![repository_id.clone()], + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: Some(resource), + resources: Vec::new(), + unresolved_failure: None, + }], + }; + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![parent], + parent_evidence: Some(evidence), + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&durable_state).expect("durable state")), + parent_targets: vec![crate::opensymphony_orchestrator::ParentRepositoryTarget { + repository_id, + checkout_handle: format!("checkout-{suffix}"), + relative_path: PathBuf::from(format!("repositories/{suffix}")), + target_branch: "develop".to_owned(), + target_commit: format!("commit-{suffix}"), + instruction_path: PathBuf::from("AGENTS.md"), + instruction_hash: format!("instruction-{suffix}"), + repair_policy: test_repair_policy(), + }], + ..Default::default() + }, + FakeWorker::default(), + config, + ); + scheduler.tick(ts(100)).await.expect("launch parent"); + assert_eq!(scheduler.worker().launches.len(), 1); + (scheduler, parent_id) +} + +fn repair_provider_snapshot( + pull_request: bool, +) -> crate::opensymphony_orchestrator::ParentRepairProviderSnapshot { + crate::opensymphony_orchestrator::ParentRepairProviderSnapshot { + pull_request_id: pull_request.then(|| "41".to_owned()), + pull_request_url: pull_request.then(|| "https://github.com/acme/repo/pull/41".to_owned()), + head_commit: pull_request.then(|| "repair-commit".to_owned()), + review_head_commit: pull_request.then(|| "repair-commit".to_owned()), + review_request_cursor: None, + open: pull_request, + checks_passed: false, + checks_failed: false, + review_approved: false, + review_rejected: false, + changes_requested: false, + review_feedback: Vec::new(), + mergeable: true, + merge_conflict: false, + merged: false, + merge_result_commit: None, + target_contains_merge_result: false, + provider_available: true, + } +} + +fn mark_parent_terminal_for_state_lookup( + scheduler: &mut Scheduler, + parent_id: &IssueId, + suffix: &str, + updated_at: u64, +) { + mark_parent_inactive_for_state_lookup( + scheduler, + parent_id, + suffix, + "Done", + "completed", + updated_at, + ); +} + +fn mark_parent_inactive_for_state_lookup( + scheduler: &mut Scheduler, + parent_id: &IssueId, + suffix: &str, + state: &str, + tracker_type: &str, + updated_at: u64, +) { + scheduler.tracker_mut().active.clear(); + scheduler.tracker_mut().terminal.clear(); + scheduler.tracker_mut().states.insert( + parent_id.to_string(), + tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + state, + tracker_type, + updated_at, + ), + ); +} + +#[tokio::test] +async fn parent_repair_searches_before_each_side_effect_and_keeps_one_pr() { + let suffix = "REPAIR-IDEMPOTENT"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-1", ts(110)) + .await + .expect("begin repair"); + assert_eq!(scheduler.workspace().repair_branch_reconciliations, 1); + assert_eq!(scheduler.workspace().repair_branch_creates, 1); + + assert_eq!( + scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-1", ts(111)) + .await + .expect("replay begin"), + repair_id + ); + assert_eq!(scheduler.workspace().repair_branch_creates, 1); + + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + let url = scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("publish repair"); + assert_eq!(url, "https://github.com/acme/repo/pull/41"); + assert_eq!(scheduler.workspace().repair_pushes, 1); + assert_eq!(scheduler.tracker().repair_pull_request_creates, 1); + + scheduler.workspace_mut().repair_remote_head = Some("repair-commit".to_owned()); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(121)) + .await + .expect("publish recovery"); + assert_eq!(scheduler.workspace().repair_pushes, 1); + assert_eq!(scheduler.tracker().repair_pull_request_creates, 1); +} + +#[tokio::test] +async fn parent_repair_persists_intent_before_branch_creation() { + let suffix = "REPAIR-PERSIST-BRANCH"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler + .workspace_mut() + .persist_durable_state_results + .push_back(Err(FakeError { + message: "persist repair intent failed".to_owned(), + category: None, + retry_after: None, + })); + assert!( + scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-persist", ts(110)) + .await + .is_err() + ); + assert_eq!(scheduler.workspace().repair_branch_reconciliations, 0); + assert_eq!(scheduler.workspace().repair_branch_creates, 0); + + scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-persist", ts(111)) + .await + .expect("retry after persistence recovery"); + assert_eq!(scheduler.workspace().repair_branch_reconciliations, 1); + assert_eq!(scheduler.workspace().repair_branch_creates, 1); +} + +#[tokio::test] +async fn parent_repair_recovers_a_created_branch_before_repeating_creation() { + let suffix = "REPAIR-BRANCH-CRASH"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler + .workspace_mut() + .persist_durable_state_results + .extend([ + Ok(()), + Ok(()), + Ok(()), + Err(FakeError { + message: "crash after branch creation".to_owned(), + category: None, + retry_after: None, + }), + ]); + assert!( + scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-branch", ts(110)) + .await + .is_err() + ); + assert_eq!(scheduler.workspace().repair_branch_creates, 1); + scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-branch", ts(111)) + .await + .expect("branch lookup recovers external result"); + assert_eq!(scheduler.workspace().repair_branch_creates, 1); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let create = state.parent_integrations[&parent_id] + .repair_attempts + .first() + .expect("repair") + .operations + .iter() + .find(|operation| { + operation.kind + == crate::opensymphony_orchestrator::ParentProviderOperationKind::CreateBranch + }) + .expect("create branch operation"); + assert_eq!( + create + .receipt + .as_ref() + .map(|receipt| receipt.status.as_str()), + Some("reconciled") + ); +} + +#[tokio::test] +async fn parent_repair_recovers_a_pushed_commit_before_repeating_push() { + let suffix = "REPAIR-PUSH-CRASH"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-push", ts(110)) + .await + .expect("begin repair"); + scheduler + .workspace_mut() + .persist_durable_state_results + .extend([ + Ok(()), + Ok(()), + Ok(()), + Err(FakeError { + message: "crash after push".to_owned(), + category: None, + retry_after: None, + }), + ]); + assert!( + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .is_err() + ); + assert_eq!(scheduler.workspace().repair_pushes, 1); + + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(121)) + .await + .expect("push reconciliation"); + assert_eq!(scheduler.workspace().repair_pushes, 1); +} + +#[tokio::test] +async fn parent_repair_recovers_a_created_pull_request_before_repeating_creation() { + let suffix = "REPAIR-PR-CRASH"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-pr", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .workspace_mut() + .persist_durable_state_results + .extend([ + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Err(FakeError { + message: "crash after pull request creation".to_owned(), + category: None, + retry_after: None, + }), + ]); + assert!( + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .is_err() + ); + assert_eq!(scheduler.tracker().repair_pull_request_creates, 1); + + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(121)) + .await + .expect("pull request reconciliation"); + assert_eq!(scheduler.tracker().repair_pull_request_creates, 1); +} + +#[tokio::test] +async fn parent_repair_review_merge_and_refresh_follow_durable_policy() { + let suffix = "REPAIR-MERGE"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-merge", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("publish repair"); + + let mut approved = repair_provider_snapshot(true); + approved.checks_passed = true; + approved.review_approved = true; + scheduler + .tracker_mut() + .repair_snapshots + .extend([repair_provider_snapshot(true), approved.clone()]); + assert_eq!( + scheduler + .request_parent_repair_review(&parent_id, &repair_id, ts(130)) + .await + .expect("review request"), + crate::opensymphony_orchestrator::ParentRepairStatus::AwaitingMerge + ); + assert_eq!(scheduler.tracker().repair_review_requests, 1); + + let mut merged = approved.clone(); + merged.open = false; + merged.merged = true; + merged.merge_result_commit = Some("target-after-squash".to_owned()); + merged.target_contains_merge_result = true; + scheduler + .tracker_mut() + .repair_snapshots + .extend([approved, merged]); + assert_eq!( + scheduler + .merge_parent_repair(&parent_id, &repair_id, ts(140)) + .await + .expect("merge repair"), + crate::opensymphony_orchestrator::ParentRepairStatus::Refreshing + ); + assert_eq!(scheduler.tracker().repair_merge_requests, 1); + scheduler.workspace_mut().repair_refresh_commit = Some("target-after-squash".to_owned()); + scheduler.workspace_mut().repair_refresh_instruction_hash = + Some("instruction-after-squash".to_owned()); + assert_eq!( + scheduler + .refresh_parent_repair(&parent_id, &repair_id, ts(150)) + .await + .expect("refresh repair"), + "target-after-squash" + ); + assert_eq!(scheduler.workspace().repair_refreshes, 1); + let durable: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + durable.parent_integrations[&parent_id] + .repair(&repair_id) + .expect("repair") + .status, + crate::opensymphony_orchestrator::ParentRepairStatus::Completed + ); +} + +#[tokio::test] +async fn scheduler_tick_drives_failed_parent_verification_through_repair_and_back_to_verification() +{ + let suffix = "REPAIR-RUN-LOOP"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + enqueue_failed_parent_repair_request(&mut scheduler, repository_id.clone(), 150); + + scheduler + .tick(ts(150)) + .await + .expect("failed verification should prepare and queue the repair implementation"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let repair_id = state.parent_integrations[&parent_id].repair_attempts[0] + .id + .clone(); + assert_eq!( + state.parent_integrations[&parent_id] + .repair(&repair_id) + .expect("repair") + .status, + crate::opensymphony_orchestrator::ParentRepairStatus::Implementing + ); + assert_eq!(scheduler.workspace().repair_branch_creates, 1); + assert_eq!(scheduler.workspace().repair_pushes, 0); + assert_eq!(scheduler.tracker().repair_pull_request_creates, 0); + + scheduler + .tick(ts(3_600_150)) + .await + .expect("the due continuation should launch repair implementation"); + assert_eq!(scheduler.worker().launches.len(), 2); + assert_eq!( + scheduler.worker().launches[1] + .parent_repair + .as_ref() + .map(|repair| repair.id.as_str()), + Some(repair_id.as_str()) + ); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.workspace_mut().repair_has_uncommitted_changes = true; + scheduler + .workspace_mut() + .repair_publish_results + .push_back(Err(FakeError { + message: "transient repair push failure token=secret".to_owned(), + category: None, + retry_after: None, + })); + enqueue_successful_parent_completion(&mut scheduler, suffix, 3_600_200); + scheduler + .tick(ts(3_600_200)) + .await + .expect("one repair publication failure must not abort the scheduler tick"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let repair = state.parent_integrations[&parent_id] + .repair(&repair_id) + .expect("repair"); + assert_eq!( + repair.status, + crate::opensymphony_orchestrator::ParentRepairStatus::Implementing + ); + assert!( + repair + .last_advancement_failure + .as_ref() + .is_some_and(|failure| failure.detail.contains("token=[redacted]")) + ); + scheduler + .tick(ts(3_600_201)) + .await + .expect("the next scheduler tick should retry repair publication"); + assert_eq!(scheduler.workspace().repair_pushes, 2); + assert_eq!(scheduler.tracker().repair_pull_request_creates, 1); + + let mut approved = repair_provider_snapshot(true); + approved.checks_passed = true; + approved.review_approved = true; + scheduler + .tracker_mut() + .repair_snapshots + .push_back(approved.clone()); + scheduler + .tick(ts(3_600_210)) + .await + .expect("provider approval should make the repair mergeable"); + + let mut merged = approved.clone(); + merged.open = false; + merged.merged = true; + merged.merge_result_commit = Some("target-after-squash".to_owned()); + merged.target_contains_merge_result = true; + scheduler + .tracker_mut() + .repair_snapshots + .extend([approved, merged]); + scheduler + .tick(ts(3_600_220)) + .await + .expect("eligible repair should merge through the scheduler loop"); + + scheduler.workspace_mut().repair_refresh_commit = Some("target-after-squash".to_owned()); + scheduler.workspace_mut().repair_refresh_instruction_hash = + Some("instruction-after-squash".to_owned()); + scheduler.workspace_mut().repair_refresh_instruction_path = Some(PathBuf::from("AGENTS.md")); + scheduler.workspace_mut().parent_targets[0].target_commit = "target-after-squash".to_owned(); + scheduler.workspace_mut().parent_targets[0].instruction_hash = + "instruction-after-squash".to_owned(); + scheduler + .tick(ts(3_600_230)) + .await + .expect("post-merge refresh should queue final verification"); + scheduler + .tick(ts(20_000_000)) + .await + .expect("the due continuation should launch final verification"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id] + .repair(&repair_id) + .expect("repair") + .status, + crate::opensymphony_orchestrator::ParentRepairStatus::Completed + ); + assert_eq!(scheduler.workspace().repair_refreshes, 1); + assert_eq!( + scheduler.worker().launches.len(), + 3, + "the scheduler must return to the real parent verification worker path" + ); + assert_eq!( + scheduler.worker().launches[2] + .parent_repair + .as_ref() + .map(|repair| repair.id.as_str()), + None, + "the post-merge turn is final verification, not repair implementation" + ); + enqueue_successful_parent_completion(&mut scheduler, suffix, 20_000_050); + scheduler + .tick(ts(20_000_050)) + .await + .expect("refreshed final verification should finish through worker updates"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let controller = &state.parent_integrations[&parent_id]; + assert_eq!( + controller.attempts.last().map(|attempt| attempt.status), + Some(crate::opensymphony_orchestrator::ParentAttemptStatus::Passed) + ); + assert_eq!(controller.repair_attempts.len(), 1); +} + +#[tokio::test] +async fn terminal_parent_between_repair_turns_cancels_and_persists_its_controller() { + let suffix = "REPAIR-WAIT-CANCEL"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + enqueue_failed_parent_repair_request(&mut scheduler, repository_id, 150); + scheduler + .tick(ts(150)) + .await + .expect("failed verification queues repair implementation"); + scheduler + .tick(ts(3_600_150)) + .await + .expect("repair implementation launches"); + enqueue_successful_parent_completion(&mut scheduler, suffix, 3_600_200); + scheduler + .tick(ts(3_600_200)) + .await + .expect("repair publication reaches provider wait"); + + let before: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert!(matches!( + before.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::AwaitingFixReview { .. } + )); + assert!( + before.parent_integrations[&parent_id] + .current_attempt_id() + .is_none(), + "provider waits have no live harness turn" + ); + assert!(before.parent_integrations[&parent_id].can_cancel_without_harness()); + let review_requests_before_terminal = scheduler.tracker().repair_review_requests; + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + + mark_parent_terminal_for_state_lookup(&mut scheduler, &parent_id, suffix, 7_300_300); + scheduler + .tick(ts(7_300_300)) + .await + .expect("terminal reconciliation cancels the provider-waiting controller"); + + let persisted: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("restart should decode the canceled controller"); + assert!(matches!( + persisted.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::Canceled { .. } + )); + let cancellation = persisted.parent_integrations[&parent_id] + .transitions + .last() + .expect("terminal cancellation transition"); + assert!(cancellation.side_effect_intent.is_none()); + assert!(cancellation.result_receipt.is_none()); + assert_eq!( + scheduler.execution(&parent_id).expect("parent").status(), + SchedulerStatus::Released + ); + assert_eq!( + scheduler.tracker().repair_review_requests, + review_requests_before_terminal, + "fresh terminal state must fence review writes before repair advancement" + ); +} + +#[tokio::test] +async fn inactive_parent_fences_a_pending_repair_review_before_reconciliation() { + let suffix = "REPAIR-INACTIVE-FENCE"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-inactive", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("publish repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + + mark_parent_inactive_for_state_lookup( + &mut scheduler, + &parent_id, + suffix, + "Human Review", + "started", + 3_900_300, + ); + scheduler + .tick(ts(3_900_300)) + .await + .expect("inactive parent should be fenced before provider advancement"); + + assert_eq!( + scheduler.tracker().repair_review_requests, + 0, + "absence from the fresh active set must fence review writes" + ); +} + +#[tokio::test] +async fn terminal_parent_during_repair_implementation_cancels_before_publication() { + let suffix = "REPAIR-TURN-TERMINAL"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + enqueue_failed_parent_repair_request(&mut scheduler, repository_id, 150); + scheduler + .tick(ts(150)) + .await + .expect("failed verification queues repair implementation"); + scheduler + .tick(ts(3_600_150)) + .await + .expect("repair implementation launches"); + enqueue_successful_parent_completion(&mut scheduler, suffix, 3_600_200); + mark_parent_terminal_for_state_lookup(&mut scheduler, &parent_id, suffix, 3_600_200); + + scheduler + .tick(ts(3_600_200)) + .await + .expect("terminal tracker refresh cancels the completed repair turn"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let controller = &state.parent_integrations[&parent_id]; + assert!(matches!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::Canceled { .. } + )); + let repair = controller.repair_attempts.first().expect("repair"); + assert!(!repair.implementation_completed); + assert_eq!(scheduler.workspace().repair_pushes, 0); +} + +#[tokio::test] +async fn terminal_parent_fences_a_pending_repair_push() { + let suffix = "REPAIR-PUSH-CANCEL"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.workspace_mut().repair_has_uncommitted_changes = true; + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + scheduler + .workspace_mut() + .repair_publish_results + .push_back(Err(FakeError { + message: "transient repair push failure".to_owned(), + category: None, + retry_after: None, + })); + enqueue_failed_parent_repair_request(&mut scheduler, repository_id, 150); + scheduler.tick(ts(150)).await.expect("prepare repair"); + scheduler + .tick(ts(3_600_150)) + .await + .expect("launch repair implementation"); + enqueue_successful_parent_completion(&mut scheduler, suffix, 3_600_200); + scheduler + .tick(ts(3_600_200)) + .await + .expect("retain completed implementation after push failure"); + let pushes_before_terminal = scheduler.workspace().repair_pushes; + assert_eq!(pushes_before_terminal, 1); + + mark_parent_terminal_for_state_lookup(&mut scheduler, &parent_id, suffix, 7_300_300); + scheduler + .tick(ts(7_300_300)) + .await + .expect("terminal state fences the pending push"); + + assert_eq!( + scheduler.workspace().repair_pushes, + pushes_before_terminal, + "fresh terminal state must fence a repair push before repair advancement" + ); +} + +#[tokio::test] +async fn terminal_parent_fences_a_pending_repair_merge() { + let suffix = "REPAIR-MERGE-CANCEL"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-merge-cancel", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("publish repair"); + let mut approved = repair_provider_snapshot(true); + approved.checks_passed = true; + approved.review_approved = true; + scheduler + .tracker_mut() + .repair_snapshots + .extend([repair_provider_snapshot(true), approved.clone()]); + scheduler + .request_parent_repair_review(&parent_id, &repair_id, ts(130)) + .await + .expect("advance to merge"); + let merges_before_terminal = scheduler.tracker().repair_merge_requests; + scheduler.tracker_mut().repair_snapshots.push_back(approved); + + mark_parent_terminal_for_state_lookup(&mut scheduler, &parent_id, suffix, 3_900_300); + scheduler + .tick(ts(3_900_300)) + .await + .expect("terminal state fences the pending merge"); + + assert_eq!( + scheduler.tracker().repair_merge_requests, + merges_before_terminal, + "fresh terminal state must fence a merge before repair advancement" + ); +} + +#[tokio::test] +async fn parent_repair_without_a_commit_requeues_implementation() { + let suffix = "REPAIR-NO-COMMIT"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + enqueue_failed_parent_repair_request(&mut scheduler, repository_id, 150); + + scheduler + .tick(ts(150)) + .await + .expect("failed verification should queue repair implementation"); + scheduler + .tick(ts(3_600_150)) + .await + .expect("repair implementation should launch"); + enqueue_successful_parent_completion(&mut scheduler, suffix, 3_600_200); + scheduler + .tick(ts(3_600_200)) + .await + .expect("a no-commit repair should remain actionable"); + + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let repair = state.parent_integrations[&parent_id] + .repair_attempts + .first() + .expect("repair"); + assert_eq!( + repair.status, + crate::opensymphony_orchestrator::ParentRepairStatus::Implementing + ); + assert!(!repair.implementation_completed); + assert_eq!(scheduler.workspace().repair_pushes, 0); + + scheduler + .tick(ts(3_600_201)) + .await + .expect("the actionable repair should queue another continuation"); + assert_eq!( + scheduler.execution(&parent_id).expect("parent").status(), + SchedulerStatus::RetryQueued + ); + scheduler + .tick(ts(7_200_201)) + .await + .expect("the due repair continuation should relaunch"); + assert_eq!(scheduler.worker().launches.len(), 3); + assert!(scheduler.worker().launches[2].parent_repair.is_some()); +} + +#[tokio::test] +async fn unobserved_failed_command_cannot_authorize_parent_repair() { + let suffix = "REPAIR-UNOBSERVED-COMMAND"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let run = scheduler.worker().launches[0].run.clone(); + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let controller = &state.parent_integrations[&parent_id]; + let mut evidence = parent_verification_evidence( + run.worker_id.as_str(), + controller.hierarchy_generation, + repository_id.clone(), + &controller.targets[&repository_id].target_commit, + ); + evidence.repair_repository_id = Some(repository_id); + let mut outcome = WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + ts(150), + Some("worker supplied an unobserved repair receipt".to_owned()), + Some("integration failed".to_owned()), + ); + outcome.parent_verification = Some(evidence); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: run.worker_id, + outcome, + }); + + scheduler + .tick(ts(150)) + .await + .expect("invalid repair evidence should fall back to the normal retry path"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert!( + state.parent_integrations[&parent_id] + .repair_attempts + .is_empty() + ); + assert_eq!(scheduler.workspace().repair_branch_creates, 0); +} + +#[tokio::test] +async fn parent_repair_implementation_honors_the_scheduler_retry_limit() { + let suffix = "REPAIR-RETRY-LIMIT"; + let mut config = scheduler_config(); + config.max_retry_attempts = Some(0); + let (mut scheduler, parent_id) = launched_parent_scheduler_with_config(suffix, config).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + enqueue_failed_parent_repair_request(&mut scheduler, repository_id, 150); + + scheduler + .tick(ts(150)) + .await + .expect("repair retry exhaustion should be durable"); + assert!(matches!( + scheduler.execution(&parent_id).expect("parent").state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::RetryExhausted, + .. + } + )); + assert_eq!(scheduler.worker().launches.len(), 1); + scheduler + .tick(ts(3_600_200)) + .await + .expect("an exhausted repair must remain parked"); + assert_eq!(scheduler.worker().launches.len(), 1); +} + +#[tokio::test] +async fn hierarchy_supersession_fences_pending_parent_repair_provider_writes() { + let suffix = "REPAIR-HIERARCHY-FENCE"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-fenced", ts(110)) + .await + .expect("begin repair"); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("publish repair"); + + scheduler.tracker_mut().active[0] + .sub_issues + .push(TrackerIssueRef { + id: "replacement-repair-fence".to_owned(), + identifier: "COE-REPLACEMENT-REPAIR-FENCE".to_owned(), + title: Some("Replacement child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + scheduler + .tick(ts(3_600_200)) + .await + .expect("hierarchy supersession should fence repair writes"); + + assert_eq!(scheduler.tracker().repair_review_requests, 0); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.hierarchy[&parent_id].blocked_reason, + Some(crate::opensymphony_orchestrator::HierarchyBlockedReason::HierarchyChanged) + ); + assert!(state.parent_integrations[&parent_id].state.terminal()); +} + +#[tokio::test] +async fn parent_repair_provider_rate_limit_honors_retry_after() { + let suffix = "REPAIR-PROVIDER-COOLDOWN"; + let (mut scheduler, _parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + enqueue_failed_parent_repair_request(&mut scheduler, repository_id, 150); + scheduler + .tick(ts(150)) + .await + .expect("failed verification should queue repair implementation"); + scheduler + .tick(ts(3_600_150)) + .await + .expect("repair implementation should launch"); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.workspace_mut().repair_has_uncommitted_changes = true; + enqueue_successful_parent_completion(&mut scheduler, suffix, 3_600_200); + scheduler + .tick(ts(3_600_200)) + .await + .expect("successful repair implementation should publish"); + let prior_requests = scheduler.tracker().repair_snapshot_requests; + scheduler + .tracker_mut() + .repair_snapshot_errors + .push_back(FakeError::rate_limited(Duration::from_secs(60))); + + scheduler + .tick(ts(3_600_201)) + .await + .expect("provider rate limit should preserve the repair for retry"); + assert_eq!( + scheduler.tracker().repair_snapshot_requests, + prior_requests + 1 + ); + scheduler + .tick(ts(3_600_300)) + .await + .expect("cooldown tick should not repeat provider lookup"); + assert_eq!( + scheduler.tracker().repair_snapshot_requests, + prior_requests + 1 + ); +} + +#[tokio::test] +async fn parent_repair_revalidates_provider_policy_immediately_before_merge() { + let suffix = "REPAIR-PREMERGE"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-premerge", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("publish repair"); + let mut approved = repair_provider_snapshot(true); + approved.checks_passed = true; + approved.review_approved = true; + scheduler + .tracker_mut() + .repair_snapshots + .extend([repair_provider_snapshot(true), approved]); + scheduler + .request_parent_repair_review(&parent_id, &repair_id, ts(130)) + .await + .expect("review approval"); + + let mut approval_dismissed = repair_provider_snapshot(true); + approval_dismissed.checks_passed = true; + scheduler + .tracker_mut() + .repair_snapshots + .push_back(approval_dismissed); + assert_eq!( + scheduler + .merge_parent_repair(&parent_id, &repair_id, ts(140)) + .await + .expect("fresh policy snapshot"), + crate::opensymphony_orchestrator::ParentRepairStatus::AwaitingReview + ); + assert_eq!(scheduler.tracker().repair_merge_requests, 0); +} + +#[tokio::test] +async fn exhausted_parent_review_budget_blocks_without_recording_a_noop_request() { + let suffix = "REPAIR-REVIEW-BUDGET"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-budget", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("publish repair"); + scheduler.tracker_mut().repair_review_budget_exhausted = true; + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + + assert_eq!( + scheduler + .request_parent_repair_review(&parent_id, &repair_id, ts(130)) + .await + .expect("review ceiling should become durable operator state"), + crate::opensymphony_orchestrator::ParentRepairStatus::ReviewBudgetExhausted + ); + assert_eq!(scheduler.tracker().repair_review_requests, 0); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let controller = &state.parent_integrations[&parent_id]; + assert_eq!( + controller.repair(&repair_id).expect("repair").status, + crate::opensymphony_orchestrator::ParentRepairStatus::ReviewBudgetExhausted + ); + assert!(matches!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::Blocked { ref reason } + if reason.contains("exact-commit local review") + )); + assert!( + !controller + .repair(&repair_id) + .expect("repair") + .operations + .iter() + .any(|operation| { + operation.kind + == crate::opensymphony_orchestrator::ParentProviderOperationKind::RequestReview + }) + ); + + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + scheduler + .reconcile_parent_repair(&parent_id, &repair_id, ts(131)) + .await + .expect("provider reconciliation should preserve the explicit review ceiling"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id] + .repair(&repair_id) + .expect("repair") + .status, + crate::opensymphony_orchestrator::ParentRepairStatus::ReviewBudgetExhausted + ); +} + +#[tokio::test] +async fn requested_change_publication_pushes_the_new_local_head_to_the_same_pull_request() { + let suffix = "REPAIR-REQUESTED-CHANGE-PUSH"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit-1".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-review", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) + .await + .expect("first publication"); + + let mut changes = repair_provider_snapshot(true); + changes.head_commit = Some("repair-commit-1".to_owned()); + changes.review_head_commit = Some("repair-commit-1".to_owned()); + changes.changes_requested = true; + scheduler.tracker_mut().repair_snapshots.push_back(changes); + scheduler + .reconcile_parent_repair(&parent_id, &repair_id, ts(130)) + .await + .expect("requested changes"); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit-2".to_owned()); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(true)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(140)) + .await + .expect("updated publication"); + + assert_eq!(scheduler.workspace().repair_pushes, 2); + assert_eq!(scheduler.tracker().repair_pull_request_creates, 1); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id] + .repair(&repair_id) + .expect("repair") + .pushed_commit + .as_deref(), + Some("repair-commit-2") + ); +} + +#[tokio::test] +async fn parent_repair_recovers_review_and_merge_results_without_duplicate_writes() { + let suffix = "REPAIR-REVIEW-MERGE-CRASH"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let repository_id = scheduler.workspace().parent_targets[0] + .repository_id + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + scheduler.workspace_mut().repair_push_commit = Some("repair-commit".to_owned()); + scheduler.tracker_mut().repair_pull_request = Some(( + "41".to_owned(), + "https://github.com/acme/repo/pull/41".to_owned(), + )); + let repair_id = scheduler + .begin_parent_repair(&parent_id, &repository_id, "defect-crash", ts(110)) + .await + .expect("begin repair"); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(repair_provider_snapshot(false)); + scheduler + .publish_parent_repair(&parent_id, &repair_id, ts(120)) .await - .expect("cooldown tick should still run"); - assert_eq!(scheduler.tracker().parent_evidence_requests, 1); -} + .expect("publish repair"); -async fn launched_parent_scheduler( - suffix: &str, -) -> (Scheduler, IssueId) { - launched_parent_scheduler_with_config(suffix, scheduler_config()).await -} + let mut approved = repair_provider_snapshot(true); + approved.checks_passed = true; + approved.review_approved = true; + scheduler + .tracker_mut() + .repair_snapshots + .extend([repair_provider_snapshot(true), approved.clone()]); + scheduler + .workspace_mut() + .persist_durable_state_results + .extend([ + Ok(()), + Ok(()), + Ok(()), + Err(FakeError { + message: "crash after review request".to_owned(), + category: None, + retry_after: None, + }), + ]); + assert!( + scheduler + .request_parent_repair_review(&parent_id, &repair_id, ts(130)) + .await + .is_err() + ); + assert_eq!(scheduler.tracker().repair_review_requests, 1); + scheduler + .tracker_mut() + .repair_snapshots + .push_back(approved.clone()); + assert_eq!( + scheduler + .request_parent_repair_review(&parent_id, &repair_id, ts(131)) + .await + .expect("review reconciliation"), + crate::opensymphony_orchestrator::ParentRepairStatus::AwaitingMerge + ); + assert_eq!(scheduler.tracker().repair_review_requests, 1); -async fn launched_parent_scheduler_with_config( - suffix: &str, - config: SchedulerConfig, -) -> (Scheduler, IssueId) { - let parent_id = IssueId::new(format!("parent-{suffix}")).expect("parent id"); - let child_id = IssueId::new(format!("child-{suffix}")).expect("child id"); - let parent_identifier = format!("COE-PARENT-{suffix}"); - let child_identifier = format!("COE-CHILD-{suffix}"); - let mut parent = tracker_issue(parent_id.as_str(), &parent_identifier, "In Progress", 0); - parent.sub_issues = vec![TrackerIssueRef { - id: child_id.to_string(), - identifier: child_identifier, - title: Some("Child".to_owned()), - url: None, - state: "Done".to_owned(), - state_kind: TrackerIssueStateKind::Completed, - }]; - let snapshot = HierarchySnapshot::new(&parent); - let repository_id = - CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"); - let resource = LeaseResource { - issue_id: child_id.clone(), - repository_id: repository_id.clone(), - checkout_generation: format!("checkout-{suffix}"), - }; - let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { - hierarchy: BTreeMap::from([(parent_id.clone(), snapshot.clone())]), - leases: vec![LeaseRecord { - kind: LeaseKind::LeafWorker, - resource: resource.clone(), - owner: LeaseOwner::leaf_worker(&child_id), - hierarchy_generation: snapshot.generation, - acquired_at: 1, - expires_at: None, - released_at: None, - }], - terminal_orchestrator_issues: [child_id.clone()].into_iter().collect(), - ..Default::default() - }; - let evidence = ParentEligibilityEvidence { - hierarchy_generation: snapshot.generation, - children: vec![ChildEligibilityEvidence { - child_id, - hierarchy_generation: snapshot.generation, - orchestrator_terminal: true, - provider_merge_confirmed: true, - merge_required: true, - merge_result_commit: Some(format!("commit-{suffix}")), - merge_result_commits: Vec::new(), - merge_result_commits_by_repository: vec![RequiredMergeCommit { - repository_id: Some(repository_id.clone()), - commit: format!("commit-{suffix}"), - }], - merge_repository_id: Some(repository_id.clone()), - merge_repository_ids: vec![repository_id.clone()], - provider_evidence_at: None, - provider_evidence_by_issue: Vec::new(), - resource: Some(resource), - resources: Vec::new(), - unresolved_failure: None, - }], - }; - let mut scheduler = Scheduler::new( - FakeTracker { - active: vec![parent], - parent_evidence: Some(evidence), - ..Default::default() - }, - FakeWorkspace { - durable_state: Some(serde_json::to_value(&durable_state).expect("durable state")), - parent_targets: vec![crate::opensymphony_orchestrator::ParentRepositoryTarget { - repository_id, - checkout_handle: format!("checkout-{suffix}"), - relative_path: PathBuf::from(format!("repositories/{suffix}")), - target_commit: format!("commit-{suffix}"), - }], - ..Default::default() - }, - FakeWorker::default(), - config, + let mut merged = approved.clone(); + merged.open = false; + merged.merged = true; + merged.merge_result_commit = Some("target-after-rebase".to_owned()); + merged.target_contains_merge_result = true; + scheduler + .tracker_mut() + .repair_snapshots + .extend([approved, merged.clone()]); + scheduler + .workspace_mut() + .persist_durable_state_results + .extend([ + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Err(FakeError { + message: "crash after merge".to_owned(), + category: None, + retry_after: None, + }), + ]); + assert!( + scheduler + .merge_parent_repair(&parent_id, &repair_id, ts(140)) + .await + .is_err() ); - scheduler.tick(ts(100)).await.expect("launch parent"); - assert_eq!(scheduler.worker().launches.len(), 1); - (scheduler, parent_id) + assert_eq!(scheduler.tracker().repair_merge_requests, 1); + scheduler.tracker_mut().repair_snapshots.push_back(merged); + assert_eq!( + scheduler + .merge_parent_repair(&parent_id, &repair_id, ts(141)) + .await + .expect("merge reconciliation"), + crate::opensymphony_orchestrator::ParentRepairStatus::Refreshing + ); + assert_eq!(scheduler.tracker().repair_merge_requests, 1); } fn enqueue_successful_parent_completion( @@ -888,7 +2458,13 @@ fn enqueue_successful_parent_completion( suffix: &str, finished_at: u64, ) { - let run = scheduler.worker().launches[0].run.clone(); + let run = scheduler + .worker() + .launches + .last() + .expect("parent launch") + .run + .clone(); let command = "cargo test-system-duckdb --test integration"; scheduler .worker_mut() @@ -932,7 +2508,8 @@ fn enqueue_successful_parent_completion( ts(finished_at), Some("parent integration verified".to_owned()), None, - ); + ) + .with_harness_stopped(); let state: crate::opensymphony_orchestrator::DurableOrchestratorState = serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) .expect("durable state"); @@ -941,11 +2518,16 @@ fn enqueue_successful_parent_completion( .get(&run.issue_id) .expect("parent controller") .hierarchy_generation; + let repository_id = + CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"); + let target_commit = state.parent_integrations[&run.issue_id].targets[&repository_id] + .target_commit + .clone(); outcome.parent_verification = Some(parent_verification_evidence( run.worker_id.as_str(), hierarchy_generation, - CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"), - &format!("commit-{suffix}"), + repository_id, + &target_commit, )); scheduler .worker_mut() @@ -956,6 +2538,81 @@ fn enqueue_successful_parent_completion( }); } +fn enqueue_failed_parent_repair_request( + scheduler: &mut Scheduler, + repository_id: CanonicalRepositoryId, + finished_at: u64, +) { + let run = scheduler.worker().launches[0].run.clone(); + let command = "cargo test-system-duckdb --test integration"; + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::RuntimeEvent { + worker_id: run.worker_id.clone(), + observed_at: ts(finished_at - 20), + event_id: Some("command-final".to_owned()), + event_kind: Some("codex.item/started".to_owned()), + summary: Some("final verification started".to_owned()), + payload: Some(serde_json::json!({ + "params": {"item": { + "id": "command-final", + "type": "commandExecution", + "command": command + }} + })), + }); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::RuntimeEvent { + worker_id: run.worker_id.clone(), + observed_at: ts(finished_at - 10), + event_id: Some("command-final".to_owned()), + event_kind: Some("codex.item/completed".to_owned()), + summary: Some("final verification failed".to_owned()), + payload: Some(serde_json::json!({ + "params": {"item": { + "id": "command-final", + "type": "commandExecution", + "command": command, + "exitCode": 1, + "aggregatedOutput": "integration failed" + }} + })), + }); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let hierarchy_generation = state.parent_integrations[&run.issue_id].hierarchy_generation; + let target_commit = state.parent_integrations[&run.issue_id].targets[&repository_id] + .target_commit + .clone(); + let mut evidence = parent_verification_evidence( + run.worker_id.as_str(), + hierarchy_generation, + repository_id.clone(), + &target_commit, + ); + evidence.repair_repository_id = Some(repository_id); + let mut outcome = WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + ts(finished_at), + Some("parent integration failed and isolated one repair".to_owned()), + Some("integration failed".to_owned()), + ) + .with_harness_stopped(); + outcome.parent_verification = Some(evidence); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: run.worker_id, + outcome, + }); +} + #[tokio::test] async fn parent_runtime_command_event_is_persisted_before_worker_completion() { let (mut scheduler, parent_id) = launched_parent_scheduler("EVENT-PERSIST").await; @@ -1817,7 +3474,11 @@ async fn eligible_parent_dispatches_once_from_durable_claim_after_restart() { repository_id: resource.repository_id.clone(), checkout_handle: "checkout-repo".to_owned(), relative_path: PathBuf::from("repositories/repo"), + target_branch: "develop".to_owned(), target_commit: "merge-commit".to_owned(), + instruction_path: PathBuf::from("AGENTS.md"), + instruction_hash: "instruction-repo".to_owned(), + repair_policy: test_repair_policy(), }], ..Default::default() }; @@ -1899,7 +3560,8 @@ async fn eligible_parent_dispatches_once_from_durable_claim_after_restart() { ts(150), Some("worker claimed success despite failed verification".to_owned()), None, - ); + ) + .with_harness_stopped(); failed_outcome.parent_verification = Some(parent_verification_evidence( parent_run.worker_id.as_str(), snapshot.generation, @@ -2035,14 +3697,9 @@ async fn eligible_parent_dispatches_once_from_durable_claim_after_restart() { } #[tokio::test] -async fn failed_parent_turn_without_commands_cleans_up_before_retry() { +async fn failed_parent_turn_without_terminal_status_retains_conversation() { let (mut scheduler, parent_id) = launched_parent_scheduler("FAILED-NO-COMMAND").await; let first_run = scheduler.worker().launches[0].run.clone(); - let conversation = scheduler - .execution(&parent_id) - .and_then(|execution| execution.conversation()) - .cloned() - .expect("parent conversation"); scheduler .worker_mut() .updates @@ -2071,35 +3728,95 @@ async fn failed_parent_turn_without_commands_cleans_up_before_retry() { .cleanup .as_ref() .map(|cleanup| cleanup.status), - Some(crate::opensymphony_orchestrator::ParentCleanupStatus::Succeeded) + Some(crate::opensymphony_orchestrator::ParentCleanupStatus::Pending) ); + assert!(controller.attempts[0].harness_stopped_at.is_none()); + assert!(!controller.can_cancel_without_harness()); assert_eq!( controller.state, crate::opensymphony_orchestrator::ParentIntegrationState::RefreshingRepositories ); + scheduler.tracker_mut().active.clear(); + scheduler.tracker_mut().terminal = vec![tracker_issue( + parent_id.as_str(), + "COE-PARENT-FAILED-NO-COMMAND", + "Done", + 0, + )]; let retry_dispatch_at = retry_due_at.max(ts(3_600_100)); - scheduler - .worker_mut() - .launch_results - .push_back(Ok(WorkerLaunch { - conversation: conversation.clone(), - started_at: Some(retry_dispatch_at), - })); scheduler .tick(retry_dispatch_at) .await - .expect("retry after stopped failed turn"); - assert_eq!(scheduler.worker().launches.len(), 2); + .expect("terminal reconciliation retains an unacknowledged remote turn"); + assert_eq!(scheduler.worker().launches.len(), 1); + assert!(scheduler.execution(&parent_id).is_some()); let state: crate::opensymphony_orchestrator::DurableOrchestratorState = serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) .expect("decode state"); assert_eq!( - state.parent_integrations[&parent_id].current_attempt_id(), - Some("parent-attempt-2") + state.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::RefreshingRepositories ); } +#[tokio::test] +async fn parent_outcomes_without_terminal_evidence_retain_ownership_without_rerunning() { + for outcome_kind in [ + WorkerOutcomeKind::Failed, + WorkerOutcomeKind::TimedOut, + WorkerOutcomeKind::Stalled, + WorkerOutcomeKind::Detached, + WorkerOutcomeKind::CancelFailed, + ] { + let suffix = format!("INDETERMINATE-{outcome_kind:?}"); + let mut config = scheduler_config(); + config.max_retry_attempts = Some(0); + let (mut scheduler, parent_id) = + launched_parent_scheduler_with_config(&suffix, config).await; + let first_run = scheduler.worker().launches[0].run.clone(); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: first_run.worker_id.clone(), + outcome: WorkerOutcomeRecord::from_run( + &first_run, + outcome_kind, + ts(150), + Some("transport ended without terminal runtime evidence".to_owned()), + None, + ), + }); + + scheduler.tick(ts(150)).await.expect("record outcome"); + let retry_due_at = scheduler.executions()[&parent_id] + .retry() + .expect("retained reconciliation retry") + .due_at; + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let controller = &state.parent_integrations[&parent_id]; + assert!(controller.has_unreconciled_harness()); + assert_eq!( + controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::RefreshingRepositories + ); + + scheduler + .tick(retry_due_at.max(ts(3_600_100))) + .await + .expect("unreconciled harness should remain fenced"); + assert_eq!( + scheduler.worker().launches.len(), + 1, + "an indeterminate conversation must not be duplicated" + ); + assert!(scheduler.execution(&parent_id).is_some()); + } +} + #[tokio::test] async fn active_parent_cancellation_is_retryable_on_the_same_conversation() { let (mut scheduler, parent_id) = launched_parent_scheduler("CANCELLED").await; @@ -2120,7 +3837,8 @@ async fn active_parent_cancellation_is_retryable_on_the_same_conversation() { ts(150), Some("runtime cancelled the active turn".to_owned()), None, - ), + ) + .with_harness_stopped(), }); scheduler.tick(ts(150)).await.expect("record cancellation"); @@ -2529,7 +4247,11 @@ async fn terminal_recovery_does_not_trust_success_before_parent_controller_final repository_id: repository_id.clone(), checkout_handle: "checkout-parent".to_owned(), relative_path: PathBuf::from("repositories/parent"), + target_branch: "develop".to_owned(), target_commit: "target-parent".to_owned(), + instruction_path: PathBuf::from("AGENTS.md"), + instruction_hash: "instruction-parent".to_owned(), + repair_policy: test_repair_policy(), }], "targets:1", ts(2), diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index e9eb6d230..edcb0caff 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -537,6 +537,12 @@ impl WorkspaceManager { retained_checkouts, required_merge_commits: required_merge_commits.into_iter().collect(), instruction, + review_profile: repository.review_profile.clone(), + review_provider: repository.review_provider.clone(), + review_policy_generation: repository.review_policy_generation.clone(), + required_checks: repository.required_checks, + required_review: repository.required_review, + merge_method: repository.merge_method.clone(), }; checkout_operation_with_timeout( checkout_time_remaining(integration_deadline), @@ -657,13 +663,13 @@ impl WorkspaceManager { .ok_or_else(|| { checkout_verification(handle.workspace_path(), "parent manifest is missing") })?; - let child_checkout_map = self + let mut child_checkout_map = self .load_manifest::(&handle, &handle.child_checkouts_path()) .await? .ok_or_else(|| { checkout_verification(handle.workspace_path(), "child checkout map is missing") })?; - let pinned_child_checkout_map = self + let mut pinned_child_checkout_map = self .load_parent_child_checkout_pin(handle.workspace_key(), hierarchy_generation) .await? .ok_or_else(|| { @@ -672,6 +678,43 @@ impl WorkspaceManager { "orchestrator-owned parent checkout pin is missing", ) })?; + if let Some(pending_refresh) = self + .load_parent_child_checkout_refresh(handle.workspace_key(), hierarchy_generation) + .await? + { + let pin_path = self + .parent_child_checkout_pin_path(handle.workspace_key(), hierarchy_generation) + .await?; + self.write_parent_child_checkout_pin(&pin_path, &pending_refresh) + .await?; + self.write_manifest_atomically( + &handle, + &handle.child_checkouts_path(), + &pending_refresh, + ) + .await?; + self.remove_parent_child_checkout_refresh(handle.workspace_key(), hierarchy_generation) + .await; + child_checkout_map = pending_refresh.clone(); + pinned_child_checkout_map = pending_refresh; + } + let child_policy_migrated = + self.migrate_parent_checkout_review_policy(&mut child_checkout_map)?; + let pin_policy_migrated = + self.migrate_parent_checkout_review_policy(&mut pinned_child_checkout_map)?; + if child_policy_migrated || pin_policy_migrated { + let pin_path = self + .parent_child_checkout_pin_path(handle.workspace_key(), hierarchy_generation) + .await?; + self.write_parent_child_checkout_pin(&pin_path, &pinned_child_checkout_map) + .await?; + self.write_manifest_atomically( + &handle, + &handle.child_checkouts_path(), + &child_checkout_map, + ) + .await?; + } if manifest.schema_version != 1 || manifest.parent_issue_id != issue.issue_id || manifest.parent_identifier != issue.identifier @@ -726,6 +769,48 @@ impl WorkspaceManager { }) } + fn migrate_parent_checkout_review_policy( + &self, + map: &mut ParentChildCheckoutMap, + ) -> Result { + let mut migrated = false; + for record in map.repositories.values_mut() { + if let Some(method) = record.merge_method.as_mut() { + let canonical = method.trim().to_ascii_lowercase(); + if *method != canonical { + *method = canonical; + migrated = true; + } + } + if !record.review_profile.is_empty() + || !record.review_provider.is_empty() + || !record.review_policy_generation.is_empty() + || record.required_checks + || record.required_review + || record.merge_method.is_some() + { + continue; + } + let repository = self + .checkout_repositories + .get(&record.repository_id) + .ok_or_else(|| { + checkout_verification( + &self.config.root, + "legacy parent checkout review policy repository is unavailable", + ) + })?; + record.review_profile = repository.review_profile.clone(); + record.review_provider = repository.review_provider.clone(); + record.review_policy_generation = repository.review_policy_generation.clone(); + record.required_checks = repository.required_checks; + record.required_review = repository.required_review; + record.merge_method = repository.merge_method.clone(); + migrated = true; + } + Ok(migrated) + } + pub async fn open_parent_execution_root_at( &self, issue: &IssueDescriptor, @@ -775,6 +860,51 @@ impl WorkspaceManager { .await } + pub async fn open_parent_execution_root_at_for_repair( + &self, + issue: &IssueDescriptor, + root: &Path, + checkout_handle: &str, + repository_id: &str, + branch: &str, + ) -> Result { + let parent = self + .open_parent_execution_root_at_for_retry(issue, root) + .await?; + let target = parent + .child_checkout_map + .repositories + .get(repository_id) + .filter(|record| record.checkout_handle == checkout_handle) + .ok_or_else(|| checkout_verification(root, "repair checkout handle is stale"))?; + let target_path = parent.handle.workspace_path().join(&target.relative_path); + let current_branch = self + .git(&target_path, &["branch", "--show-current"]) + .await?; + if current_branch != branch { + return Err(checkout_verification( + &target_path, + "repair checkout is not on its recorded branch", + )); + } + for record in parent + .child_checkout_map + .repositories + .values() + .filter(|record| record.checkout_handle != checkout_handle) + { + let source = self.validate_parent_retained_checkouts(record).await?; + checkout_operation_with_timeout( + Some(RETAINED_CHECKOUT_VERIFICATION_TIMEOUT), + parent.handle.workspace_path(), + "verify non-target parent integration worktree", + self.verify_parent_integration_checkout(&parent.handle, record, &source, false), + ) + .await?; + } + Ok(parent) + } + pub async fn resolve_parent_checkout( &self, parent: &ParentExecutionRoot, @@ -909,6 +1039,372 @@ impl WorkspaceManager { Ok(instructions) } + pub async fn reconcile_parent_repair_branch( + &self, + issue: &IssueDescriptor, + parent_root: &Path, + checkout_handle: &str, + branch: &str, + target_commit: &str, + ) -> Result<(Option, String), WorkspaceError> { + let parent = self + .open_parent_execution_root_at_for_retry(issue, parent_root) + .await?; + let record = parent + .child_checkout_map + .repositories + .values() + .find(|record| record.checkout_handle == checkout_handle) + .ok_or_else(|| checkout_verification(parent_root, "repair checkout handle is stale"))?; + if record.target_commit != target_commit { + return Err(checkout_verification( + parent_root, + "repair target no longer matches the pinned integration target", + )); + } + let checkout = self + .resolve_parent_checkout(&parent, checkout_handle) + .await?; + let repository = self + .checkout_repositories + .get(&record.repository_id) + .ok_or_else(|| { + checkout_verification(&checkout, "repair repository policy is unavailable") + })?; + let instruction = self + .load_instruction_provenance(&checkout, repository, target_commit) + .await?; + let heads = self + .git( + &checkout, + &["branch", "--list", "--format=%(objectname)", branch], + ) + .await?; + let head = heads + .lines() + .find(|line| !line.trim().is_empty()) + .map(str::to_owned); + Ok((head, instruction.content_hash)) + } + + pub async fn create_parent_repair_branch( + &self, + issue: &IssueDescriptor, + parent_root: &Path, + checkout_handle: &str, + branch: &str, + target_commit: &str, + ) -> Result { + let (existing, instruction_hash) = self + .reconcile_parent_repair_branch( + issue, + parent_root, + checkout_handle, + branch, + target_commit, + ) + .await?; + if let Some(existing) = existing { + if existing == target_commit { + return Ok(instruction_hash); + } + return Err(checkout_verification( + parent_root, + "repair branch already exists at an unrecorded commit", + )); + } + let parent = self + .open_parent_execution_root_at_for_retry(issue, parent_root) + .await?; + let checkout = self + .resolve_parent_checkout(&parent, checkout_handle) + .await?; + self.git(&checkout, &["switch", "--create", branch, target_commit]) + .await?; + Ok(instruction_hash) + } + + pub async fn reconcile_parent_repair_push( + &self, + issue: &IssueDescriptor, + parent_root: &Path, + checkout_handle: &str, + repository_id: &str, + branch: &str, + ) -> Result, WorkspaceError> { + let parent = self + .open_parent_execution_root_at_for_retry(issue, parent_root) + .await?; + let checkout = self + .resolve_parent_checkout(&parent, checkout_handle) + .await?; + let repository = self + .checkout_repositories + .get(repository_id) + .ok_or_else(|| { + checkout_verification(&checkout, "repair repository policy is unavailable") + })?; + self.reject_checkout_controlled_transport_config(&checkout) + .await?; + let reference = format!("refs/heads/{branch}"); + let output = self + .run_authenticated_git( + &checkout, + repository, + &["ls-remote", "--heads", &repository.remote, &reference], + ) + .await?; + Ok(output + .split_whitespace() + .next() + .filter(|_| output.split_whitespace().nth(1) == Some(reference.as_str())) + .map(str::to_owned)) + } + + pub async fn parent_repair_has_uncommitted_changes( + &self, + issue: &IssueDescriptor, + parent_root: &Path, + checkout_handle: &str, + branch: &str, + ) -> Result { + let parent = self + .open_parent_execution_root_at_for_retry(issue, parent_root) + .await?; + let checkout = self + .resolve_parent_checkout(&parent, checkout_handle) + .await?; + let current_branch = self.git(&checkout, &["branch", "--show-current"]).await?; + if current_branch != branch { + return Err(checkout_verification( + &checkout, + "repair checkout is not on its recorded branch", + )); + } + Ok(!self + .git( + &checkout, + &["status", "--porcelain", "--untracked-files=all"], + ) + .await? + .is_empty()) + } + + pub async fn publish_parent_repair( + &self, + issue: &IssueDescriptor, + parent_root: &Path, + checkout_handle: &str, + repository_id: &str, + branch: &str, + ) -> Result { + let parent = self + .open_parent_execution_root_at_for_retry(issue, parent_root) + .await?; + let record = parent + .child_checkout_map + .repositories + .get(repository_id) + .filter(|record| record.checkout_handle == checkout_handle) + .ok_or_else(|| checkout_verification(parent_root, "repair checkout handle is stale"))?; + let checkout = self + .resolve_parent_checkout(&parent, checkout_handle) + .await?; + let repository = self + .checkout_repositories + .get(repository_id) + .ok_or_else(|| { + checkout_verification(&checkout, "repair repository policy is unavailable") + })?; + let current_branch = self.git(&checkout, &["branch", "--show-current"]).await?; + if current_branch != branch { + return Err(checkout_verification( + &checkout, + "repair checkout is not on its recorded branch", + )); + } + let status = self + .git( + &checkout, + &["status", "--porcelain", "--untracked-files=all"], + ) + .await?; + if !status.is_empty() { + self.git(&checkout, &["add", "--all"]).await?; + let message = format!("fix: repair parent integration ({branch})"); + self.git( + &checkout, + &[ + "-c", + "user.name=OpenSymphony", + "-c", + "user.email=opensymphony@localhost", + "commit", + "--no-verify", + "--no-gpg-sign", + "--message", + &message, + ], + ) + .await?; + } + let head = self.git(&checkout, &["rev-parse", "HEAD"]).await?; + if head == record.target_commit { + return Err(checkout_verification( + &checkout, + "repair branch has no commit beyond its recorded target", + )); + } + if !self + .git_is_ancestor( + &checkout, + &["merge-base", "--is-ancestor", &record.target_commit, &head], + ) + .await? + { + return Err(checkout_verification( + &checkout, + "repair branch no longer descends from its recorded target", + )); + } + self.reject_checkout_controlled_transport_config(&checkout) + .await?; + let refspec = format!("HEAD:refs/heads/{branch}"); + self.run_authenticated_git( + &checkout, + repository, + &["push", &repository.remote, &refspec], + ) + .await?; + Ok(head) + } + + pub async fn refresh_parent_repair_target( + &self, + issue: &IssueDescriptor, + parent_root: &Path, + checkout_handle: &str, + repository_id: &str, + merge_result_commit: &str, + ) -> Result<(String, PathBuf, String), WorkspaceError> { + let mut parent = self + .open_parent_execution_root_at_for_retry(issue, parent_root) + .await?; + let existing = parent + .child_checkout_map + .repositories + .get(repository_id) + .cloned() + .filter(|record| record.checkout_handle == checkout_handle) + .ok_or_else(|| checkout_verification(parent_root, "repair checkout handle is stale"))?; + let source = self.validate_parent_retained_checkouts(&existing).await?; + let repository = self + .checkout_repositories + .get(repository_id) + .ok_or_else(|| { + checkout_verification(parent_root, "repair repository policy is unavailable") + })?; + self.fetch_parent_target(&source, repository).await?; + let target_ref = format!("refs/remotes/origin/{}", repository.target_branch); + let target_commit = self + .git(source.workspace_path(), &["rev-parse", &target_ref]) + .await?; + if !self + .git_is_ancestor( + source.workspace_path(), + &[ + "merge-base", + "--is-ancestor", + merge_result_commit, + &target_commit, + ], + ) + .await? + { + return Err(checkout_verification( + parent_root, + "provider merge result is not reachable from refreshed target", + )); + } + for required_merge_commit in &existing.required_merge_commits { + if !self + .git_is_ancestor( + source.workspace_path(), + &[ + "merge-base", + "--is-ancestor", + required_merge_commit, + &target_commit, + ], + ) + .await? + { + return Err(checkout_verification( + parent_root, + &format!( + "retained child merge result `{required_merge_commit}` is not reachable from refreshed target" + ), + )); + } + } + let checkout = self + .resolve_parent_checkout(&parent, checkout_handle) + .await?; + if !self + .git( + &checkout, + &["status", "--porcelain", "--untracked-files=all"], + ) + .await? + .is_empty() + { + return Err(checkout_verification( + &checkout, + "repair checkout is dirty during post-merge refresh", + )); + } + self.git(&checkout, &["switch", "--detach", &target_commit]) + .await?; + let instruction = self + .load_instruction_provenance(&checkout, repository, &target_commit) + .await?; + let instruction_path = instruction.path.clone(); + let instruction_hash = instruction.content_hash.clone(); + let record = parent + .child_checkout_map + .repositories + .get_mut(repository_id) + .expect("record was checked"); + record.target_commit = target_commit.clone(); + record.instruction = instruction; + self.write_parent_child_checkout_refresh( + parent.handle.workspace_key(), + parent.manifest.hierarchy_generation, + &parent.child_checkout_map, + ) + .await?; + let pin_path = self + .parent_child_checkout_pin_path( + parent.handle.workspace_key(), + parent.manifest.hierarchy_generation, + ) + .await?; + self.write_parent_child_checkout_pin(&pin_path, &parent.child_checkout_map) + .await?; + self.write_manifest_atomically( + &parent.handle, + &parent.handle.child_checkouts_path(), + &parent.child_checkout_map, + ) + .await?; + self.remove_parent_child_checkout_refresh( + parent.handle.workspace_key(), + parent.manifest.hierarchy_generation, + ) + .await; + Ok((target_commit, instruction_path, instruction_hash)) + } + pub async fn write_parent_runtime_envelope( &self, parent: &ParentExecutionRoot, @@ -1069,6 +1565,7 @@ impl WorkspaceManager { args.extend([repository.remote.as_str(), refspec.as_str()]); self.run_authenticated_git(source.workspace_path(), repository, &args) .await + .map(|_| ()) } async fn reject_checkout_controlled_transport_config( @@ -1191,7 +1688,7 @@ impl WorkspaceManager { checkout: &Path, repository: &CheckoutRepository, args: &[&str], - ) -> Result<(), WorkspaceError> { + ) -> Result { let environment_credential = repository.credential_kind == "environment"; let ssh_agent_credential = repository.credential_kind == "ssh-agent"; let environment_credential_value = environment_credential @@ -1216,9 +1713,15 @@ impl WorkspaceManager { } let askpass_path = if environment_credential { - let path = checkout - .join(".opensymphony") - .join(format!("askpass-{}", Uuid::new_v4().simple())); + let metadata_directory = checkout.join(".opensymphony"); + fs::create_dir_all(&metadata_directory) + .await + .map_err(|source| WorkspaceError::CheckoutOperation { + operation: "prepare Git credential helper".to_owned(), + path: metadata_directory.clone(), + detail: source.to_string(), + })?; + let path = metadata_directory.join(format!("askpass-{}", Uuid::new_v4().simple())); fs::write( &path, b"#!/bin/sh\ncase \"$1\" in\n *Username*) printf '%s\\n' \"$OPENSYMPHONY_CHECKOUT_USERNAME\" ;;\n *) printf '%s\\n' \"$OPENSYMPHONY_CHECKOUT_CREDENTIAL\" ;;\nesac\n", @@ -1374,7 +1877,7 @@ impl WorkspaceManager { }; #[cfg(unix)] process_group_guard.disarm(); - let _stdout = join_child_pipe(stdout_task).await.map_err(|source| { + let stdout = join_child_pipe(stdout_task).await.map_err(|source| { WorkspaceError::CheckoutOperation { operation: args.first().copied().unwrap_or("git").to_owned(), path: checkout.to_path_buf(), @@ -1392,7 +1895,7 @@ impl WorkspaceManager { let _ = fs::remove_file(path).await; } if status.success() { - return Ok(()); + return Ok(String::from_utf8_lossy(&stdout).trim().to_owned()); } let mut detail = redact_runtime_diagnostic(&String::from_utf8_lossy(&stderr)); if let Some(value) = environment_credential_value.as_deref() { @@ -1584,6 +2087,18 @@ impl WorkspaceManager { )); } } + if record.review_profile != repository.review_profile + || record.review_provider != repository.review_provider + || record.review_policy_generation != repository.review_policy_generation + || record.required_checks != repository.required_checks + || record.required_review != repository.required_review + || record.merge_method != repository.merge_method + { + return Err(checkout_verification( + &integration, + "repository review policy does not match the parent checkout map", + )); + } if !allow_worker_changes { let instruction = self .load_instruction_provenance(&integration, repository, &record.target_commit) @@ -4724,6 +5239,64 @@ impl WorkspaceManager { .await } + async fn parent_child_checkout_refresh_path( + &self, + workspace_key: &str, + hierarchy_generation: u64, + ) -> Result { + let canonical_root = self.canonicalize_path(&self.config.root).await?; + Ok(canonical_root + .join(".opensymphony-parent-pins") + .join(workspace_key) + .join(format!("{hierarchy_generation}.refresh.json"))) + } + + async fn write_parent_child_checkout_refresh( + &self, + workspace_key: &str, + hierarchy_generation: u64, + map: &ParentChildCheckoutMap, + ) -> Result<(), WorkspaceError> { + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root); + let path = self + .parent_child_checkout_refresh_path(workspace_key, hierarchy_generation) + .await?; + self.write_json_artifact_atomically(&handle, &path, map) + .await + } + + async fn load_parent_child_checkout_refresh( + &self, + workspace_key: &str, + hierarchy_generation: u64, + ) -> Result, WorkspaceError> { + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root); + let path = self + .parent_child_checkout_refresh_path(workspace_key, hierarchy_generation) + .await?; + self.load_manifest(&handle, &path).await + } + + async fn remove_parent_child_checkout_refresh( + &self, + workspace_key: &str, + hierarchy_generation: u64, + ) { + let Ok(path) = self + .parent_child_checkout_refresh_path(workspace_key, hierarchy_generation) + .await + else { + return; + }; + if let Err(error) = fs::remove_file(&path).await + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!(path = %path.display(), %error, "failed to remove completed parent refresh transaction"); + } + } + async fn load_parent_child_checkout_pin( &self, workspace_key: &str, @@ -5912,7 +6485,7 @@ pub fn compose_parent_prompt( .collect::>() .join(",\n"); format!( - "## Central Execution Procedure\n\n{central_procedure}\n\n## Parent Task Facts\n\n{task_facts}\n\n## Verified Child Checkout Map\n\nParent root: {}\nHierarchy generation: {}\n{}\n\n## Project-set Integration Instructions\n\n{}\n\n## Repository Instructions by Canonical ID\n\n{}\n\n## Final Verification Receipt\n\nRun the final integration check as one bounded foreground command from the parent root or one named checkout. Do not leave background processes running. After it exits, atomically write `evidence/final-verification.json` with this shape. `command` must be the exact shell command observed by the harness and `root` is `parent_root` or a verified checkout handle:\n\n```json\n{{\n \"schema_version\": 1,\n \"run_id\": \"{}\",\n \"attempt\": {},\n \"hierarchy_generation\": {},\n \"repository_commits\": {{\n{}\n }},\n \"command\": \"cargo test\",\n \"root\": \"parent_root\"\n}}\n```\n\nUse the exact inspected commits and the real verification command. The file only selects harness-observed evidence: OpenSymphony takes timing, exit status, bounded output, process ownership, and teardown from runtime command events. A successful harness turn or an unobserved command does not complete the parent.\n\n## Runtime Capabilities\n\nharness={} cwd={} requested_scope={} containment={}\n", + "## Central Execution Procedure\n\n{central_procedure}\n\n## Parent Task Facts\n\n{task_facts}\n\n## Verified Child Checkout Map\n\nParent root: {}\nHierarchy generation: {}\n{}\n\n## Project-set Integration Instructions\n\n{}\n\n## Repository Instructions by Canonical ID\n\n{}\n\n## Final Verification Receipt\n\nRun the final integration check as one bounded foreground command from the parent root or one named checkout. Do not leave background processes running. After it exits, atomically write `evidence/final-verification.json` with this shape. `command` must be the exact shell command observed by the harness and `root` is `parent_root` or a verified checkout handle:\n\n```json\n{{\n \"schema_version\": 1,\n \"run_id\": \"{}\",\n \"attempt\": {},\n \"hierarchy_generation\": {},\n \"repository_commits\": {{\n{}\n }},\n \"command\": \"cargo test\",\n \"root\": \"parent_root\",\n \"repair_repository_id\": null\n}}\n```\n\nIf the selected check fails because one repository needs a code repair, replace `null` with its exact canonical repository ID without editing the checkout. OpenSymphony will create the verified repair branch and resume this conversation with repair instructions. This field is only a repair request; OpenSymphony owns every Git and provider receipt. Use the exact inspected commits and the real verification command. The file only selects harness-observed evidence: OpenSymphony takes timing, exit status, bounded output, process ownership, and teardown from runtime command events. A successful harness turn or an unobserved command does not complete the parent.\n\n## Runtime Capabilities\n\nharness={} cwd={} requested_scope={} containment={}\n", envelope.workspace_path.display(), envelope.hierarchy_generation, checkout_map, @@ -5945,7 +6518,7 @@ pub fn compose_parent_continuation_prompt(envelope: &ParentRuntimeEnvelope) -> S .collect::>() .join(",\n"); format!( - "## Current Parent Verification Attempt\n\nThis continuation is bound to run `{}` attempt {} and hierarchy generation {}. Run the final integration check as one bounded foreground command from the parent root or one named checkout. After it exits, atomically write `evidence/final-verification.json` with the exact observed command and verified root:\n\n```json\n{{\n \"schema_version\": 1,\n \"run_id\": \"{}\",\n \"attempt\": {},\n \"hierarchy_generation\": {},\n \"repository_commits\": {{\n{}\n }},\n \"command\": \"cargo test\",\n \"root\": \"parent_root\"\n}}\n```\n\nThe file only selects harness-observed evidence. OpenSymphony takes timing, exit status, bounded output, process ownership, and teardown from runtime command events.\n", + "## Current Parent Verification Attempt\n\nThis continuation is bound to run `{}` attempt {} and hierarchy generation {}. Run the final integration check as one bounded foreground command from the parent root or one named checkout. After it exits, atomically write `evidence/final-verification.json` with the exact observed command and verified root:\n\n```json\n{{\n \"schema_version\": 1,\n \"run_id\": \"{}\",\n \"attempt\": {},\n \"hierarchy_generation\": {},\n \"repository_commits\": {{\n{}\n }},\n \"command\": \"cargo test\",\n \"root\": \"parent_root\",\n \"repair_repository_id\": null\n}}\n```\n\nIf the selected check fails because one repository needs a code repair, replace `null` with its exact canonical repository ID without editing the checkout. OpenSymphony will create the verified repair branch and resume this conversation with repair instructions. This field is only a repair request; OpenSymphony owns every Git and provider receipt. The file only selects harness-observed evidence. OpenSymphony takes timing, exit status, bounded output, process ownership, and teardown from runtime command events.\n", envelope.run_id, envelope.attempt, envelope.hierarchy_generation, diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index 0ed35ca3e..1787c4670 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -155,6 +155,18 @@ pub struct ParentIntegrationCheckout { pub retained_checkouts: Vec, pub required_merge_commits: Vec, pub instruction: InstructionProvenance, + #[serde(default)] + pub review_profile: String, + #[serde(default)] + pub review_provider: String, + #[serde(default)] + pub review_policy_generation: String, + #[serde(default)] + pub required_checks: bool, + #[serde(default)] + pub required_review: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_method: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1062,6 +1074,11 @@ pub struct RunManifest { #[serde(default, skip_serializing_if = "Option::is_none")] pub interrupt_reason: Option, pub status: RunStatus, + /// True only when the harness adapter observed a terminal runtime state or + /// a reconciled interrupt acknowledgement. A failed transport alone does + /// not prove the remote turn stopped. + #[serde(default)] + pub harness_stopped: bool, pub created_at: DateTime, /// Durable boundary for fencing provider evidence after a run restarts. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1094,6 +1111,7 @@ impl RunManifest { retry_error: None, interrupt_reason: None, status: RunStatus::Preparing, + harness_stopped: false, created_at: now, started_at: None, updated_at: now, @@ -1390,7 +1408,35 @@ impl SessionContextArtifact { #[cfg(test)] mod tests { - use super::redact_runtime_diagnostic; + use super::{ParentIntegrationCheckout, redact_runtime_diagnostic}; + + #[test] + fn schema_one_parent_checkout_without_review_fields_remains_readable() { + let checkout: ParentIntegrationCheckout = serde_json::from_value(serde_json::json!({ + "checkout_handle": "checkout-a", + "repository_id": "github:repository:a", + "safe_remote_fingerprint": "github:repository:a", + "relative_path": "repositories/a", + "target_branch": "develop", + "target_commit": "abc123", + "storage_source_generation": "generation-a", + "retained_checkouts": [], + "required_merge_commits": [], + "instruction": { + "path": "AGENTS.md", + "content_hash": "sha256:abc", + "source_commit": "abc123", + "source": "configured" + } + })) + .expect("schema-one checkout remains readable"); + + assert!(checkout.review_profile.is_empty()); + assert!(checkout.review_provider.is_empty()); + assert!(!checkout.required_checks); + assert!(!checkout.required_review); + assert_eq!(checkout.merge_method, None); + } #[test] fn runtime_diagnostics_redact_common_credentials_and_url_userinfo() { diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index 27aba9453..e79c3cc68 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -221,6 +221,7 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( repository_fixture(temp_dir.path(), "repository-b"); let (source_c, binding_c, repository_c) = repository_fixture(temp_dir.path(), "repository-c"); repository_a.provider_id = Some("repository-a-native-id".to_owned()); + repository_a.merge_method = Some("squash".to_owned()); binding_a.repository.safe_remote_fingerprint = SafeRemoteFingerprint::from_remote( &repository_a.provider, repository_a.provider_id.as_deref(), @@ -529,6 +530,37 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( assert!(!prepared.handle.workspace_path().join(".git").exists()); assert_eq!(prepared.child_checkout_map.repositories.len(), 3); + let pin_file = manager + .config() + .root + .join(".opensymphony-parent-pins") + .join(prepared.handle.workspace_key()) + .join("5.json"); + for path in [prepared.handle.child_checkouts_path(), pin_file] { + let mut persisted: serde_json::Value = serde_json::from_slice( + &std::fs::read(&path).expect("parent checkout map should be readable"), + ) + .expect("parent checkout map should decode"); + persisted["repositories"][binding_a.repository_id().as_str()]["merge_method"] = + serde_json::Value::String(" Squash ".to_owned()); + std::fs::write( + &path, + serde_json::to_vec_pretty(&persisted).expect("parent checkout map should encode"), + ) + .expect("legacy merge-method spelling should be persisted"); + } + let recovered = manager + .open_parent_execution_root_at_for_retry(&parent, prepared.handle.workspace_path()) + .await + .expect("case-variant persisted merge methods should migrate on restart"); + assert_eq!( + recovered + .child_checkout_map + .repositories + .get(binding_a.repository_id().as_str()) + .and_then(|record| record.merge_method.as_deref()), + Some("squash") + ); let repository_a = prepared .child_checkout_map .repositories @@ -816,6 +848,185 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( .map(String::as_str), Some("repository workflow instructions\n") ); + + let repair_branch = "fix/coe-parent-repair-1"; + let (existing_repair, instruction_hash) = manager + .reconcile_parent_repair_branch( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + repair_branch, + &repository_a.target_commit, + ) + .await + .expect("repair branch lookup"); + assert!(existing_repair.is_none()); + assert_eq!(instruction_hash, repository_a.instruction.content_hash); + manager + .create_parent_repair_branch( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + repair_branch, + &repository_a.target_commit, + ) + .await + .expect("repair branch creation"); + std::fs::write(integration.join("parent-repair.txt"), "repair\n") + .expect("repair file should be written"); + assert!( + manager + .open_parent_execution_root_at(&parent, prepared.handle.workspace_path()) + .await + .is_err(), + "ordinary final verification must reject repair edits" + ); + manager + .open_parent_execution_root_at_for_retry(&parent, prepared.handle.workspace_path()) + .await + .expect("authorized repair completion may verify dirty branch changes"); + manager + .open_parent_execution_root_at_for_repair( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + binding_a.repository_id().as_str(), + repair_branch, + ) + .await + .expect("repair completion accepts changes only on its recorded branch"); + git(&integration, &["checkout", "--detach"]); + let wrong_branch = manager + .open_parent_execution_root_at_for_repair( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + binding_a.repository_id().as_str(), + repair_branch, + ) + .await + .expect_err("detached repair work must not produce a successful receipt"); + assert!(wrong_branch.to_string().contains("recorded branch")); + git(&integration, &["checkout", repair_branch]); + let integration_b = prepared.handle.workspace_path().join( + &prepared.child_checkout_map.repositories[binding_b.repository_id().as_str()].relative_path, + ); + std::fs::write(integration_b.join("unrelated-repair.txt"), "unrelated\n") + .expect("non-target repair marker should be written"); + let dirty_non_target = manager + .open_parent_execution_root_at_for_repair( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + binding_a.repository_id().as_str(), + repair_branch, + ) + .await + .expect_err("repair completion must keep non-target checkouts clean"); + assert!(dirty_non_target.to_string().contains("dirty")); + std::fs::remove_file(integration_b.join("unrelated-repair.txt")) + .expect("non-target repair marker should be removed"); + git(&integration, &["config", "commit.gpgSign", "true"]); + git(&integration, &["config", "gpg.program", "false"]); + let repair_commit = manager + .publish_parent_repair( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + binding_a.repository_id().as_str(), + repair_branch, + ) + .await + .expect("repair push"); + assert_eq!( + manager + .reconcile_parent_repair_push( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + binding_a.repository_id().as_str(), + repair_branch, + ) + .await + .expect("repair push lookup") + .as_deref(), + Some(repair_commit.as_str()) + ); + let target_before_repair_merge = git(&source_a, &["rev-parse", "HEAD"]); + let target_tree = git(&source_a, &["write-tree"]); + let unrelated_target = git( + &source_a, + &["commit-tree", &target_tree, "-m", "unrelated target"], + ); + let unrelated_refspec = format!("{unrelated_target}:main"); + git( + &source_a, + &["push", "--force", "origin", &unrelated_refspec], + ); + let discarded_children = manager + .refresh_parent_repair_target( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + binding_a.repository_id().as_str(), + &unrelated_target, + ) + .await + .expect_err("refresh must retain every required child merge result"); + assert!( + discarded_children + .to_string() + .contains("retained child merge result") + ); + let restored_refspec = format!("{target_before_repair_merge}:main"); + git(&source_a, &["push", "--force", "origin", &restored_refspec]); + git(&source_a, &["fetch", "origin", repair_branch]); + git( + &source_a, + &["merge", "--squash", &format!("origin/{repair_branch}")], + ); + git(&source_a, &["commit", "-m", "squash parent repair"]); + git(&source_a, &["push", "origin", "main"]); + let merge_result = git(&source_a, &["rev-parse", "HEAD"]); + let (refreshed, refreshed_instruction_path, refreshed_instructions) = manager + .refresh_parent_repair_target( + &parent, + prepared.handle.workspace_path(), + &repository_a.checkout_handle, + binding_a.repository_id().as_str(), + &merge_result, + ) + .await + .expect("repair target refresh"); + assert_eq!(refreshed, merge_result); + assert_eq!( + refreshed_instructions, + repository_a.instruction.content_hash + ); + assert_eq!(refreshed_instruction_path, repository_a.instruction.path); + assert_ne!( + repair_commit, refreshed, + "squash result replaces repair commit" + ); + let pin_directory = manager + .config() + .root + .join(".opensymphony-parent-pins") + .join(prepared.handle.workspace_key()); + let pin_bytes = std::fs::read(pin_directory.join("5.json")).expect("refresh pin"); + std::fs::write(pin_directory.join("5.refresh.json"), &pin_bytes) + .expect("pending refresh transaction"); + std::fs::write( + prepared.handle.child_checkouts_path(), + serde_json::to_vec_pretty(&prepared.child_checkout_map).expect("stale runtime map"), + ) + .expect("simulate crash before runtime map promotion"); + manager + .open_parent_execution_root_at_for_retry(&parent, prepared.handle.workspace_path()) + .await + .expect("pending refresh should finish atomically on recovery"); + assert!(!pin_directory.join("5.refresh.json").exists()); + let openhands = manager.parent_runtime_envelope( &prepared, ParentRuntimeDescriptor { diff --git a/docs/architecture.md b/docs/architecture.md index 7874b6f4b..053a93689 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,6 +125,27 @@ completed parent reopens, the scheduler creates a new controller lifecycle even when its child-edge generation did not change. A bound parent conversation also fixes the harness choice for that lifecycle; a configured harness switch fails before the replacement session starts. +An integration defect creates an immutable repair attempt for one canonical +repository. The controller copies the verified target, instruction provenance, +and central review policy into that attempt, then records a durable intent and +receipt for each provider operation. Branch, push, pull-request, review, and +merge writes are preceded by provider reconciliation, so restart recovery finds +an existing result before repeating a side effect. Requested changes stay on +the same attempt and pull request. Provider outages, failed checks, review +rejection, external closure, force-push, and merge conflicts remain precise, +resumable states. GitHub is the first provider adapter and remains authoritative +for PR, review, check, and merge facts. Each scheduler tick checks a fresh full +tracker snapshot before advancing repair-provider writes, so any parent absent +from the current active set fences review, push, and merge operations in the +same observation. +After merge, the controller records the provider merge-result commit and the +workspace manager fetches the configured target through the central credential +path. The refreshed checkout is accepted only when that merge result and every +retained child merge result are reachable from the new target. Squash and +rebase results therefore do not depend on the replaced repair commit remaining +an ancestor. Both copies of the +generation-bound checkout map and its instruction hash are updated before final +verification can resume. Final evidence is accepted only from the run-bound `evidence/final-verification.json` receipt. The runtime reopens every checkout at its exact prepared commit and uses the file only to select an actual command @@ -135,7 +156,11 @@ foreground-process ownership, and teardown from those runtime events before it can pass the attempt. Generic harness success or a prompt-authored claim without matching events is insufficient. Each accepted command or resource event is persisted with the -controller before the worker reaches a terminal outcome. On timeout or cancellation, a reconciled harness stopped state +controller before the worker reaches a terminal outcome. The adapter records +stopped-turn evidence only after a terminal runtime state or acknowledged stop; +a backward-compatible run-manifest flag carries that fact across restart, and a +transport-level failed outcome cannot substitute for it. On timeout +or cancellation, a reconciled harness stopped state releases the foreground-process receipt; any other named resource remains an explicit cleanup fence. The durable final record maps every canonical repository to the exact verified commit so a higher ancestor can consume the completed parent diff --git a/docs/configuration.md b/docs/configuration.md index e607350a5..3eee08056 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,6 +64,23 @@ for SSH clone URLs, while `environment` credentials are rejected for SSH clones. Active GitHub review profiles must separately resolve to an `environment` credential with a variable name, because review API requests do not use the checkout transport credential or ambient authentication. +Parent repairs snapshot the selected repository's target branch, instruction +hash, review profile, review provider, review-policy generation, required-check +and required-review flags, and canonical lowercase merge method. Repository +instructions guide the repair but cannot weaken that central policy. GitHub PR +creation and merge use the review credential; branch fetch and push use the +checkout credential. +GitHub and Codex review profiles are supported for GitHub repair PRs; selecting +either GitHub-backed profile for a non-GitHub repository is rejected during +central configuration validation. A Codex profile +uses PR opening for its initial scan and the exact `@codex review` comment only +after a requested-change repair produces a new head. The completed review +summary closes the scan before current-head inline findings determine the +durable review result. The same current-head Codex and human evidence is used +when a completed child PR contributes merge evidence to a project-set parent. +Parent checkout maps written before these review fields existed are migrated +from the current central repository profile before reuse and written back to +both generation-bound copies. Queued retries do not advance the durable retry count until dispatch begins, so a restart during the backoff window cannot mistake a pending retry for an exhausted one. Recovery restores persisted non-exhausted retry counts before diff --git a/docs/operations.md b/docs/operations.md index 03f1cacb7..dcb503d38 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -730,6 +730,70 @@ incomplete root, a hook-created root-level Git repository also fails and rolls back, and reuse requires the hook completion receipt. Parent paths include a stable issue-identity digest after the sanitized identifier. +For a blocked parent repair, inspect the repair attempt's provider-operation +ledger before intervening. A pending operation is an intent whose provider +result must be reconciled first; do not create another branch or PR manually. +`provider_unavailable`, `failed_checks`, `review_rejected`, +`externally_closed`, `force_pushed`, and `merge_conflict` preserve the attempt +for retry or operator repair. Requested changes return the same attempt to its +recorded branch and PR. A stage-specific failure is stored as a bounded, +redacted repair diagnostic and retried without stopping the rest of the +scheduler tick. For a Codex review profile, the PR-open scan is the +initial request and later requested-change heads use an exact `@codex review` +comment; only completion and findings associated with the current pushed head +affect automated-review eligibility. Crash recovery accepts a trigger comment +only from the identity behind the configured review credential. Review findings +come from unresolved provider threads. Every unresolved non-Codex human thread, +including a `COMMENTED` review or a thread retained from an earlier head, blocks +merge until it is resolved; its bounded body and location are persisted for the +credential-scrubbed repair continuation. Resolving a thread after accepted +pushback removes it from the current finding set without requiring a no-op +commit. When the central profile requires review, +a clean Codex scan and a current human approval are both required, and a current +human change request remains authoritative. The same child merge-evidence gate +rejects every unresolved human review thread before parent integration. At most +seven later triggers are posted, so the initial scan plus retriggers cannot +exceed eight; remediation +after that ceiling records a durable `review_budget_exhausted` operator state +and uses the documented exact-commit local review path. Before +posting a later trigger, the scheduler persists the highest observed provider +comment ID so crash recovery can find the exact write despite GitHub's +second-precision timestamps. Recovery +keeps the cursor captured before the pending trigger instead of replacing it +with a later reconciliation snapshot. The +scheduler applies a fresh provider snapshot immediately before merge and returns +to review if approval, checks, or mergeability are no longer current. A merged +provider snapshot advances only when the pushed head and policy evidence remain +current and the durable ledger contains the orchestrator's pending merge intent +for that exact head. A replacement repair push supersedes older pending merge +intents; an external merge that bypasses those facts is blocked for operator +recovery. Repair +provider writes remain pending whenever a fresh tracker snapshot does not list +the parent in its active set, the controller is terminal, or the current +hierarchy generation is fenced. A +terminal or canceled tracker transition also persists controller cancellation +while the repair is between harness turns in pull-request, review, merge, or +refresh processing; no harness interrupt is emitted when no turn is running. A +repair implementation interrupted by restart remains in `fixing` for cleanup and +retry instead of entering the final-verification refresh path. A failed parent +verification selects the repository but does not publish immediately: the +scheduler creates the repair branch, resumes the same parent conversation for an +observed implementation-and-check turn, and only then commits and pushes through +the workspace owner. If that turn leaves no commit beyond the recorded target, +the scheduler clears its completion marker and queues another implementation +continuation. A repair request is accepted only when its receipt selects +a completed command observed by the harness before the attempt deadline. Repair +implementation retries stop at the configured scheduler limit, and provider +rate-limit responses defer all repair lookups until their retry delay expires. +After a provider merge, refresh fetches the configured target, proves the +recorded merge-result commit and every retained child merge result are +reachable, and refreshes complete instruction provenance before final +verification runs again. The configured repair merge +call selects merge, squash, or rebase centrally. Historical child merge evidence +can prove a merge commit from its multi-parent topology; GitHub does not expose +enough evidence to distinguish squash from rebase by a single-parent commit +alone, so an ambiguous result remains ineligible. + Strict `opensymphony rehydrate` also derives the desired repository, harness, model, and generation envelope from the current central routing inventory before creating a replacement conversation. If that envelope differs from the diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 15b717b8d..bc337e6e0 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -81,6 +81,16 @@ Why fakes matter: isolation, legacy missing-controller migration before same-conversation reattachment, terminal-success finalization gates, and retry after a failed durable outcome write +- parent repair branch, push, pull-request, review, merge, and refresh + reconciliation with side-effect counters; persistence failure before branch + creation; bounded current-head feedback for credential-scrubbed workers; + resolved-thread pushback; controller cancellation between repair turns and + when tracker state changes before a completed implementation turn publishes; + retention after a conversation-bound transport failure without terminal proof; + requested changes on one PR; failed checks, rejection, outage, + external closure, force-push, and conflict states; squash/rebase merge-result + reachability; retained-child reachability after target refresh; inherited + commit-signing isolation; and separately auditable attempts across repositories ## 2.4 Live local tests diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index f23d24f40..e7c4e3555 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -207,10 +207,17 @@ manager. Once that conversation is bound, a parent harness change is rejected before a replacement session starts. A runtime-terminal successful, failed, or canceled turn supplies stopped-turn evidence and releases its foreground-process receipt; -timeout, stall, detach, and failed-cancel paths still require explicit stopped -state reconciliation. An ordinary cancellation while the tracker parent remains +an outcome created by transport loss or local persistence failure does not. A +timeout, stall, detach, or failed-cancel path still requires explicit stopped +state reconciliation. The run manifest persists this adapter observation as a +separate backward-compatible flag; a legacy or current `failed` status alone is +not terminal harness evidence during restart recovery. An ordinary cancellation while the tracker parent remains active returns through cleanup and baseline refresh. Operator and tracker -terminal cancellation remains terminal. +terminal cancellation remains terminal. When a tracker terminal state arrives +between repair worker turns, cancellation records no harness-interrupt intent or +acknowledgement only after every attempt has conclusive stopped and cleanup +evidence. A conversation-bound indeterminate attempt remains retained for stop +reconciliation or operator recovery. The final-verification receipt selects an exact harness-observed foreground command. The trusted loader computes its SHA-256 identity from the transient @@ -284,6 +291,49 @@ admission. Parents with no recorded checkout targets still require an observed successful final command and successful cleanup; their final repository commit map is empty by definition. +A parent repair uses the affected repository's existing contained integration +worktree. The workspace manager resolves it through the opaque checkout handle, +reloads the pinned instruction provenance, and creates a fresh +`fix/-g-repair-` branch at the recorded +target. The hierarchy generation keeps a reactivated or replanned parent from +colliding with a retained branch from an earlier controller lifecycle. Managed +Git operations isolate hooks and reject checkout-controlled transport settings. +If crash recovery finds the deterministic branch after its create call, the +workspace reconciliation completes the original branch intent before the repair +advances. A successful repair turn that leaves the branch at its target commit +returns to implementation instead of retrying publication forever. +Pushes use the configured credential path and reconcile the exact remote branch +and local intended head before mutation. Requested-change commits reuse that +branch and pull request, while old-head review results cannot advance the new +head. The provider adapter reads unresolved review threads, persists a bounded +copy of current-head feedback, and includes that copy in the repair continuation +so a private-repository worker does not need provider credentials. Resolved +threads, including accepted same-thread pushback, stop counting as findings on +the unchanged head. One repair does not open or modify another repository's +checkout. + +The repair lease and completed parent root remain durable after refresh. Final +verification resumes only after every affected checkout and both copies of its +generation-bound map contain a reachable post-merge target and refreshed +instruction path and hash. A failed harness-observed parent check may request +one canonical repository repair; the scheduler verifies the repository and +owns branch, pull-request, review, merge, and refresh receipts before it queues +the next final-verification turn. The failed verification turn only selects the +repository. The scheduler creates its repair branch and resumes the same parent +conversation with that authorized checkout; publication waits for a successful, +harness-observed repair turn. Ordinary final verification still requires exact +clean targets, while the repair turn may verify descendant or dirty work on its +recorded repair branch before the workspace owner commits and pushes it. Its +successful receipt requires that exact branch, and every non-target checkout +must remain pinned and clean. Restart recovery canonicalizes historical +case-variant merge methods in both the runtime checkout map and its generation +pin before comparing them with current central policy. An +externally observed merge advances only with current pushed-head policy evidence +and a pending scheduler-owned merge intent bound to that head. Publishing a +replacement head supersedes any older pending merge intent. Capture +acknowledgement and ordered release of evidence-protecting roots and leases +remain part of the later cleanup lifecycle. + The parent runtime artifact records the complete relative checkout map, requested `parent_multi_checkout` scope, harness/model selection, and truthful `trusted_host` or `workspace_confined` containment. Parent memory grants remain From 524104c43a5560e13430118a07f04f9583de7a59 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sun, 13 Sep 2026 01:27:57 -0500 Subject: [PATCH 07/29] docs(memory): capture COE-555 and sync project docs Capture the merged parent repair lifecycle evidence and regenerate the managed documentation from the complete 172-issue memory corpus. Preserve existing history while recording PR #238 and its merge source. Validation: full sync-docs preview, memory lint --public-docs, and git diff --check passed; no pending docs or capture warnings. --- .opensymphony/memory/memory.yaml | 147 +++++++++++++++++++++++++++++++ docs/accessibility.md | 2 + docs/build.md | 2 + docs/cli.md | 2 + docs/code-intelligence.md | 2 + docs/code-review.md | 2 + docs/codex-runtime.md | 2 + docs/codex.md | 2 + docs/configuration.md | 2 + docs/control.md | 2 + docs/data-contracts.md | 4 +- docs/debugging.md | 2 + docs/desktop.md | 2 + docs/developer-experience.md | 2 + docs/docs.md | 2 + docs/documentation.md | 2 + docs/duckdb.md | 2 + docs/evidence.md | 4 +- docs/experiments.md | 3 + docs/frontend.md | 2 + docs/gateway.md | 2 + docs/graph-view.md | 2 + docs/harness-runtime.md | 2 + docs/harness.md | 3 + docs/installer.md | 2 + docs/linear.md | 2 + docs/mcp.md | 2 + docs/memory.md | 2 + docs/migration.md | 2 + docs/model-configuration.md | 2 + docs/okf.md | 2 + docs/openhands-runtime.md | 2 + docs/operations.md | 2 + docs/orchestrator.md | 2 + docs/outcomes.md | 3 + docs/planning.md | 2 + docs/process-protocol.md | 3 + docs/provenance.md | 3 + docs/provider.md | 37 ++++++++ docs/quality.md | 4 +- docs/query-packs.md | 2 + docs/release.md | 2 + docs/repository-layout.md | 2 + docs/review.md | 37 ++++++++ docs/run-detail.md | 2 + docs/runner.md | 3 + docs/runtime.md | 2 + docs/rust.md | 2 + docs/scheduler.md | 3 + docs/security.md | 2 + docs/target-repo-bootstrap.md | 2 + docs/task-packets.md | 3 + docs/template.md | 2 + docs/testing-and-operations.md | 2 + docs/testing.md | 2 + docs/tests.md | 2 + docs/threejs.md | 2 + docs/tui.md | 2 + docs/ui.md | 2 + docs/verifiers.md | 3 + docs/web.md | 2 + docs/workflow.md | 2 + docs/workspace-lifecycle.md | 2 + docs/workspace.md | 3 + 64 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 docs/provider.md create mode 100644 docs/review.md diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index 6b4a4910c..e31408eb1 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -57,9 +57,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -181,9 +183,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -270,9 +274,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -350,9 +356,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -420,9 +428,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -498,9 +508,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -601,9 +613,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -673,9 +687,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -760,9 +776,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -820,7 +838,9 @@ areas: - area:outcomes - area:process-protocol - area:provenance + - area:provider - area:quality + - area:review - area:runner - area:scheduler - area:task-packets @@ -871,9 +891,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -963,9 +985,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -1051,9 +1075,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -1176,9 +1202,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -1255,9 +1283,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -1333,9 +1363,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -1402,7 +1434,9 @@ areas: - area:outcomes - area:process-protocol - area:provenance + - area:provider - area:quality + - area:review - area:runner - area:scheduler - area:task-packets @@ -1428,6 +1462,8 @@ areas: - area:harness - area:memory - area:orchestrator + - area:provider + - area:review - area:scheduler - area:testing - area:workspace-lifecycle @@ -1473,9 +1509,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -1550,9 +1588,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -1630,9 +1670,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -1673,6 +1715,8 @@ areas: - area:harness - area:memory - area:orchestrator + - area:provider + - area:review - area:testing - area:workspace-lifecycle linear_milestones: @@ -1725,9 +1769,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -1889,9 +1935,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -1975,9 +2023,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -2077,9 +2127,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -2169,9 +2221,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -2253,9 +2307,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -2342,9 +2398,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -2428,9 +2486,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -2535,9 +2595,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -2615,9 +2677,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -2708,9 +2772,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -2762,6 +2828,8 @@ areas: - area:memory - area:orchestrator - area:outcomes + - area:provider + - area:review - area:testing - area:verifiers - area:workspace-lifecycle @@ -2807,9 +2875,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -2869,6 +2939,8 @@ areas: - area:memory - area:orchestrator - area:process-protocol + - area:provider + - area:review - area:runner - area:testing - area:workspace-lifecycle @@ -2889,12 +2961,29 @@ areas: - area:memory - area:orchestrator - area:provenance + - area:provider + - area:review - area:task-packets - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' - 'M1: Task And Run Orchestration' + provider: + title: Provider + docs_target: docs/provider.md + visibility: public + status: stable + confidence: 95 + aliases: + - provider + source_refs: + linear_labels: + - area:provider + - area:review + - area:workspace-lifecycle + linear_milestones: + - 'M12.96: Parent Integration Lifecycle' quality: title: Quality docs_target: docs/quality.md @@ -2913,7 +3002,9 @@ areas: - area:outcomes - area:process-protocol - area:provenance + - area:provider - area:quality + - area:review - area:runner - area:scheduler - area:task-packets @@ -2965,9 +3056,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3034,9 +3127,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3084,6 +3179,20 @@ areas: - 'M12.6: Tree-sitter Code Intelligence' - 'M12.96: Parent Integration Lifecycle' - 'M9.5: Developer Build Acceleration' + review: + title: Review + docs_target: docs/review.md + visibility: public + status: stable + confidence: 95 + aliases: + - review + source_refs: + linear_labels: + - area:review + - area:workspace-lifecycle + linear_milestones: + - 'M12.96: Parent Integration Lifecycle' run-detail: title: Run Detail docs_target: docs/run-detail.md @@ -3124,9 +3233,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3167,6 +3278,8 @@ areas: - area:harness - area:memory - area:orchestrator + - area:provider + - area:review - area:runner - area:testing - area:workspace-lifecycle @@ -3220,9 +3333,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -3299,9 +3414,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3343,6 +3460,8 @@ areas: - area:harness - area:memory - area:orchestrator + - area:provider + - area:review - area:scheduler - area:testing - area:workspace-lifecycle @@ -3396,9 +3515,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -3510,9 +3631,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3554,6 +3677,8 @@ areas: - area:harness - area:memory - area:orchestrator + - area:provider + - area:review - area:task-packets - area:testing - area:workspace @@ -3600,9 +3725,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3677,9 +3804,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -3788,9 +3917,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3858,9 +3989,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -3935,9 +4068,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -4021,9 +4156,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:runtime @@ -4088,6 +4225,8 @@ areas: - area:harness - area:memory - area:orchestrator + - area:provider + - area:review - area:testing - area:verifiers - area:workspace-lifecycle @@ -4133,9 +4272,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -4217,9 +4358,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust @@ -4261,6 +4404,8 @@ areas: - area:harness - area:memory - area:orchestrator + - area:provider + - area:review - area:testing - area:workspace - area:workspace-lifecycle @@ -4321,9 +4466,11 @@ areas: - area:planning - area:process-protocol - area:provenance + - area:provider - area:quality - area:query-packs - area:release + - area:review - area:run-detail - area:runner - area:rust diff --git a/docs/accessibility.md b/docs/accessibility.md index d1aa73546..edc616616 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/build.md b/docs/build.md index 5c0168311..61bd541fa 100644 --- a/docs/build.md +++ b/docs/build.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/cli.md b/docs/cli.md index 2d2bc7c44..0d894d48b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -119,6 +119,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -214,6 +215,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index 8460113a7..d4b1e8fad 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -275,6 +275,7 @@ references, and never returns source snippets. - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -332,6 +333,7 @@ references, and never returns source snippets. - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-review.md b/docs/code-review.md index d7d90b106..270a6896f 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index a90506c4b..6e1904828 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex.md b/docs/codex.md index 4451a804c..27566f274 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/configuration.md b/docs/configuration.md index 3eee08056..6ca4b46f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1096,6 +1096,7 @@ copy that guide into the target repository. - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1251,6 +1252,7 @@ copy that guide into the target repository. - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/control.md b/docs/control.md index 0e5fb4d30..7f0d26dd5 100644 --- a/docs/control.md +++ b/docs/control.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 45f38fb95..afdcc88d4 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -13,10 +13,10 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. -- COE-565 contributed: No merged PR source was matched during capture. ## Important invariants @@ -35,6 +35,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -46,6 +47,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/debugging.md b/docs/debugging.md index 42be34111..2c56e2c2d 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/desktop.md b/docs/desktop.md index 18e87b429..e33e9ba8a 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -222,6 +223,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index f7907f33f..d2804cec3 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/docs.md b/docs/docs.md index 2d0735ad0..e16b958d6 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -221,6 +222,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/documentation.md b/docs/documentation.md index bbe76be61..310f7cd06 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/duckdb.md b/docs/duckdb.md index bcb435830..74c642f10 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/evidence.md b/docs/evidence.md index a9bd51a16..ebd2d36b1 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -13,10 +13,10 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. -- COE-565 contributed: No merged PR source was matched during capture. ## Important invariants @@ -35,6 +35,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -46,6 +47,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/experiments.md b/docs/experiments.md index 3e2b00f8c..20c9d7d96 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-566: Implement configurable run matrices and scheduling ## Source refs - COE-553 - COE-554 +- COE-555 - COE-566 diff --git a/docs/frontend.md b/docs/frontend.md index 50c04728d..9f6b5a927 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/gateway.md b/docs/gateway.md index 602350898..76eef150c 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -143,6 +143,7 @@ baseline, not an issue-workspace overlay. - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -242,6 +243,7 @@ baseline, not an issue-workspace overlay. - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/graph-view.md b/docs/graph-view.md index c2602a5bc..47a8c0d64 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index 0a1436477..c75e1d950 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness.md b/docs/harness.md index f36bf4b38..46d5d460b 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) ## Important invariants @@ -31,10 +32,12 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle ## Source refs - COE-553 - COE-554 +- COE-555 diff --git a/docs/installer.md b/docs/installer.md index c818313d7..5b0aed0b0 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/linear.md b/docs/linear.md index 0c6e075e4..d798559e1 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/mcp.md b/docs/mcp.md index 7c9961fd3..11e24d833 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/memory.md b/docs/memory.md index 3ae6b5506..9da648cd6 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -816,6 +816,7 @@ are covered by the same archive operation. - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -979,6 +980,7 @@ are covered by the same archive operation. - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/migration.md b/docs/migration.md index 45ae278ef..8b73b7d9c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index fa49262fe..490aeae3b 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/okf.md b/docs/okf.md index ab36c4799..81c42d357 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index 860441aef..4b4b4e846 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/operations.md b/docs/operations.md index dcb503d38..14200ceb5 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1035,6 +1035,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1201,6 +1202,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index af410fcb0..5de19d613 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/outcomes.md b/docs/outcomes.md index 9f2df1a3d..bfd9fbd90 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-564: Implement verifier execution and outcome records ## Source refs - COE-553 - COE-554 +- COE-555 - COE-564 diff --git a/docs/planning.md b/docs/planning.md index d9766c325..5288351a1 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index 08d9c5389..c6c23506a 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs - COE-553 - COE-554 +- COE-555 - COE-567 diff --git a/docs/provenance.md b/docs/provenance.md index 671d2dc95..2ec61fb38 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-563 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-563: Implement task-packet admission and freeze tooling ## Source refs - COE-553 - COE-554 +- COE-555 - COE-563 diff --git a/docs/provider.md b/docs/provider.md new file mode 100644 index 000000000..275de5a0d --- /dev/null +++ b/docs/provider.md @@ -0,0 +1,37 @@ +--- +type: topic-doc +area: provider +visibility: public +last_memory_sync: 2026-09-13T06:27:30.577415+00:00 +--- + +# Provider + + + +## Current model + +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) + +## Important invariants + +- Preserve the behavior described in the recent captured changes unless current code and tests show it has changed. +- Use capsule source refs to inspect the original PR or Linear issue when context is ambiguous. + +## Operational flow + +- No generated diagram requested for this sync. + +## Known gotchas + +- No area-specific gotchas were inferred from the selected memory. + +## Recent changes + +- COE-555: Parent Repair Review And Merge Lifecycle + +## Source refs + +- COE-555 + + diff --git a/docs/quality.md b/docs/quality.md index 92d4f71c8..ca1edead3 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -13,10 +13,10 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. - COE-564 contributed: No merged PR source was matched during capture. -- COE-565 contributed: No merged PR source was matched during capture. ## Important invariants @@ -35,6 +35,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -46,6 +47,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/query-packs.md b/docs/query-packs.md index 6f91776a6..7096bb913 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/release.md b/docs/release.md index da4e637fd..e59960c4d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 719798a00..b7ff73a21 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -248,6 +248,7 @@ of dead bridge code. - COE-508: Cache code-intel parsers and compiled query packs - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle ## Source refs @@ -311,5 +312,6 @@ of dead bridge code. - COE-508 - COE-553 - COE-554 +- COE-555 diff --git a/docs/review.md b/docs/review.md new file mode 100644 index 000000000..71454b788 --- /dev/null +++ b/docs/review.md @@ -0,0 +1,37 @@ +--- +type: topic-doc +area: review +visibility: public +last_memory_sync: 2026-09-13T06:27:30.581384+00:00 +--- + +# Review + + + +## Current model + +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) + +## Important invariants + +- Preserve the behavior described in the recent captured changes unless current code and tests show it has changed. +- Use capsule source refs to inspect the original PR or Linear issue when context is ambiguous. + +## Operational flow + +- No generated diagram requested for this sync. + +## Known gotchas + +- No area-specific gotchas were inferred from the selected memory. + +## Recent changes + +- COE-555: Parent Repair Review And Merge Lifecycle + +## Source refs + +- COE-555 + + diff --git a/docs/run-detail.md b/docs/run-detail.md index cb18ca6ea..5fc3c42a4 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/runner.md b/docs/runner.md index aa2c8a56d..e3392572a 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs - COE-553 - COE-554 +- COE-555 - COE-567 diff --git a/docs/runtime.md b/docs/runtime.md index 435ea8331..5e09a1544 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/rust.md b/docs/rust.md index 488d5bbfc..931634125 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/scheduler.md b/docs/scheduler.md index dc7483c59..a970f85d8 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-566: Implement configurable run matrices and scheduling ## Source refs - COE-553 - COE-554 +- COE-555 - COE-566 diff --git a/docs/security.md b/docs/security.md index 3fcf8197e..a67b19928 100644 --- a/docs/security.md +++ b/docs/security.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index 4aae2db83..2474f8f3e 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/task-packets.md b/docs/task-packets.md index 7d91e9daf..2a55650de 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-563 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. @@ -33,6 +34,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-563: Implement task-packet admission and freeze tooling - COE-565: Implement isolated workspace materialization @@ -40,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-553 - COE-554 +- COE-555 - COE-563 - COE-565 diff --git a/docs/template.md b/docs/template.md index c16fca582..10540be6e 100644 --- a/docs/template.md +++ b/docs/template.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index bc337e6e0..0f55cac01 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -789,6 +789,7 @@ That document covers: - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle ## Source refs @@ -939,5 +940,6 @@ That document covers: - COE-550 - COE-551 - COE-554 +- COE-555 diff --git a/docs/testing.md b/docs/testing.md index 2a67c1d9b..728852412 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -218,6 +219,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/tests.md b/docs/tests.md index 059694ce5..a50881f21 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/threejs.md b/docs/threejs.md index e7508fde9..086029f65 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/tui.md b/docs/tui.md index 4ff2f41ee..455ebab8e 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -210,6 +211,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/ui.md b/docs/ui.md index 20ef984fc..8b5292b06 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -117,6 +117,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/verifiers.md b/docs/verifiers.md index fa0502713..f5f2ff36f 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-564: Implement verifier execution and outcome records ## Source refs - COE-553 - COE-554 +- COE-555 - COE-564 diff --git a/docs/web.md b/docs/web.md index 82bd0316d..4a1505bd1 100644 --- a/docs/web.md +++ b/docs/web.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/workflow.md b/docs/workflow.md index 43c7f34aa..cfd8516a4 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index 7ade23ec7..0be2f8a6b 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -78,6 +78,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -132,6 +133,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-551 - COE-553 - COE-554 +- COE-555 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace.md b/docs/workspace.md index 01eb7cd80..2223f7ffc 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) +- COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-565 contributed: No merged PR source was matched during capture. ## Important invariants @@ -32,12 +33,14 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller +- COE-555: Parent Repair Review And Merge Lifecycle - COE-565: Implement isolated workspace materialization ## Source refs - COE-553 - COE-554 +- COE-555 - COE-565 From bc471f836f926427219a961f3efe3af7516134ce Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sun, 13 Sep 2026 06:51:32 -0500 Subject: [PATCH 08/29] COE-556: Add bottom-up subtree cleanup and recovery (#239) * feat(lifecycle): add bottom-up subtree cleanup Summary: - persist capture-acknowledged parent cleanup intents, receipts, and generation tombstones - detach parent Git worktrees before releasing owner leases and deleting descendant generations bottom-up - resume incomplete cleanup across restart while preserving higher owners, retention policy, and harness archive fences - document and test cleanup ordering, retry, hook, tombstone, and hold cases Rationale: - completed parent roots must survive capture retries, then clean up without invalidating registered worktrees or evidence owned by another ancestor - destructive retries need exact-generation proof and durable step receipts rather than treating any missing path as success Tests: - GIT_CONFIG_GLOBAL=/dev/null CARGO_INCREMENTAL=0 cargo test-system-duckdb - CARGO_INCREMENTAL=0 cargo check-system-duckdb - CARGO_INCREMENTAL=0 cargo clippy-system-duckdb - cargo fmt --check - git diff --check * fix(lifecycle): harden cleanup recovery * fix(lifecycle): preserve terminal cleanup policy * fix(lifecycle): recover nested cleanup state * fix(lifecycle): fence cleanup state transitions * fix(lifecycle): resume retained cleanup on reopen * fix(lifecycle): preserve captured cleanup generation * fix(lifecycle): recover interrupted subtree cleanup * fix(lifecycle): fence active cleanup generations * fix(lifecycle): close cleanup crash windows Summary: - roll back owner lease release when its durable receipt cannot persist - fence best-effort hooks before execution and fail closed on malformed state - recover completed parent capture routing after parent root removal Rationale: - restart cleanup must never repeat side effects or invalidate live worktrees - durable completion must survive deletion of the parent run manifest Tests: - cargo test-system-duckdb targeted scheduler, runtime, and workspace tests - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(lifecycle): fence cleanup retries by generation Summary: - fence retry-queued descendants and roll back failed completion receipts - preserve newer-generation memory grants during old-generation cleanup - require OpenHands parent conversation archival before cleanup preparation Rationale: - delayed retries and newer runs must keep their workspace and authorization - every destructive completion and archival transition needs durable evidence Tests: - cargo test-system-duckdb targeted scheduler, memory, and runtime tests - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(lifecycle): refresh tracker before cleanup retries Summary: - refresh live tracker state before every pending destructive subtree retry - fence newly active unresolved or matching descendant workspace generations - reject missing launched conversation evidence before generation cleanup Rationale: - reopen events must reach the scheduler before retained checkouts are removed - absence of a harness manifest cannot bypass required conversation archival Tests: - cargo test-system-duckdb targeted capture, retry, and runtime tests - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(lifecycle): avoid retained cleanup refresh churn Summary: - force fresh tracker snapshots only for destructive or policy-resumed cleanup - keep intentionally retained failed parents on the normal refresh cadence - cover consecutive retained cleanup ticks with tracker request assertions Rationale: - diagnostic retention can persist indefinitely without creating API pressure Tests: - cargo test-system-duckdb targeted retention, cleanup, and runtime tests - cargo check-system-duckdb - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check --- crates/opensymphony-cli/src/memory.rs | 348 ++++- .../src/orchestrator_run/backends.rs | 457 +++++- .../src/orchestrator_run/mod.rs | 57 +- crates/opensymphony-domain/src/lib.rs | 15 + .../opensymphony-domain/src/state_machine.rs | 25 +- .../src/hierarchy.rs | 89 +- crates/opensymphony-orchestrator/src/lib.rs | 5 +- .../src/parent_integration.rs | 71 +- .../src/scheduler.rs | 663 ++++++++- .../tests/scheduler.rs | 1255 +++++++++++++++-- crates/opensymphony-workspace/src/error.rs | 14 + crates/opensymphony-workspace/src/lib.rs | 3 +- crates/opensymphony-workspace/src/manager.rs | 694 ++++++++- crates/opensymphony-workspace/src/models.rs | 84 +- .../tests/workspace_manager.rs | 291 +++- docs/architecture.md | 41 +- docs/memory.md | 35 +- docs/operations.md | 28 + docs/testing-and-operations.md | 11 +- docs/workspace-and-lifecycle.md | 78 +- 20 files changed, 3995 insertions(+), 269 deletions(-) diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index 013a74c0a..8f6c721fc 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -67,8 +67,9 @@ use crate::{ }, opensymphony_workflow::{ResolvedWorkflow, WorkflowDefinition}, opensymphony_workspace::{ - CleanupConfig, HookConfig, IssueManifest, RunManifest, RunStatus, WorkspaceManager, - WorkspaceManagerConfig, checkout_workspace_key, workspace_path_for_root, + CleanupConfig, HookConfig, IssueManifest, ParentExecutionManifest, RunManifest, RunStatus, + WorkspaceManager, WorkspaceManagerConfig, checkout_workspace_key, parent_workspace_key, + workspace_path_for_root, }, }; @@ -560,8 +561,28 @@ fn load_terminal_capture_bindings_inner( let mut bindings = BTreeMap::new(); for candidate in terminal_capture_workspace_candidates(&root)? { let run_path = candidate.join(".opensymphony/run.json"); - let Ok(raw) = fs::read_to_string(&run_path) else { - continue; + let raw = match fs::read_to_string(&run_path) { + Ok(raw) => raw, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let Some((identifier, binding)) = + prelaunch_parent_capture_binding(&root, &candidate)? + else { + continue; + }; + let key = identifier.to_ascii_lowercase(); + if requested.is_some_and(|requested| !requested.contains(&key)) { + continue; + } + if let Some(previous) = bindings.insert(key, binding.clone()) + && previous != binding + { + return Err(MemoryError::InvalidInput(format!( + "multiple immutable parent manifests were found for `{identifier}`" + ))); + } + continue; + } + Err(_) => continue, }; let Ok(run) = serde_json::from_str::(&raw) else { continue; @@ -714,6 +735,111 @@ fn load_terminal_capture_bindings_inner( Ok(bindings) } +fn prelaunch_parent_capture_binding( + workspace_root: &Path, + candidate: &Path, +) -> Result, MemoryError> { + let manifest_path = candidate.join(".opensymphony/parent.json"); + let raw = match fs::read_to_string(&manifest_path) { + Ok(raw) => raw, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Ok(None), + }; + let Ok(manifest) = serde_json::from_str::(&raw) else { + return Ok(None); + }; + let workspace_path = candidate + .canonicalize() + .map_err(|source| MemoryError::ResolvePath { + path: candidate.to_path_buf(), + source, + })?; + let manifest_workspace_path = + manifest + .workspace_path + .canonicalize() + .map_err(|source| MemoryError::ResolvePath { + path: manifest.workspace_path.clone(), + source, + })?; + let expected_parent_key = + parent_workspace_key(&manifest.parent_identifier, &manifest.parent_issue_id).map_err( + |error| { + MemoryError::InvalidInput(format!( + "invalid parent manifest ownership for `{}`: {error}", + manifest.parent_identifier + )) + }, + )?; + let expected_workspace_path = workspace_root + .join("parents") + .join(expected_parent_key) + .join(manifest.hierarchy_generation.to_string()); + if workspace_path != manifest_workspace_path + || workspace_path != expected_workspace_path + || !workspace_path.starts_with(workspace_root) + { + return Err(MemoryError::InvalidInput(format!( + "parent manifest for `{}` is outside its configured workspace", + manifest.parent_identifier + ))); + } + let state_path = workspace_root.join(".opensymphony-orchestrator-state.json"); + let state_raw = match fs::read_to_string(&state_path) { + Ok(raw) => raw, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(MemoryError::ReadFile { + path: state_path, + source, + }); + } + }; + let state: DurableOrchestratorState = serde_json::from_str(&state_raw).map_err(|error| { + MemoryError::InvalidInput(format!( + "failed to decode durable parent controller state before terminal capture: {error}" + )) + })?; + state.validate().map_err(|error| { + MemoryError::InvalidInput(format!( + "invalid durable parent controller state before terminal capture: {error}" + )) + })?; + let Some(controller) = state + .parent_integrations + .iter() + .find_map(|(issue_id, controller)| { + (issue_id.as_str() == manifest.parent_issue_id).then_some(controller) + }) + else { + return Ok(None); + }; + if controller.hierarchy_generation != manifest.hierarchy_generation + || !matches!( + controller.state, + ParentIntegrationState::Failed { .. } | ParentIntegrationState::Canceled { .. } + ) + || controller.has_unreconciled_harness() + || controller.has_unreconciled_provider_operation() + { + return Ok(None); + } + Ok(Some(( + manifest.parent_identifier, + TerminalCaptureBinding { + parent_integration: true, + repository_id: String::new(), + run_id: String::new(), + attempt: 0, + target_branch: String::new(), + target_commit: String::new(), + checkout_head: String::new(), + instruction_hash: String::new(), + repository_commits: BTreeMap::new(), + }, + ))) +} + fn terminal_capture_workspace_candidates(root: &Path) -> Result, MemoryError> { let parents_root = root.join("parents"); let mut candidates = child_directories(root)?; @@ -771,9 +897,6 @@ fn completed_parent_capture_commits( run: &RunManifest, envelope: &crate::opensymphony_workspace::ParentRuntimeEnvelope, ) -> Result>, MemoryError> { - if run.status != RunStatus::Succeeded { - return Ok(None); - } let state_path = workspace_root.join(".opensymphony-orchestrator-state.json"); let raw = match fs::read_to_string(&state_path) { Ok(raw) => raw, @@ -802,9 +925,22 @@ fn completed_parent_capture_commits( else { return Ok(None); }; - if controller.state != ParentIntegrationState::Completed - || controller.hierarchy_generation != envelope.hierarchy_generation - { + if controller.hierarchy_generation != envelope.hierarchy_generation { + return Ok(None); + } + if matches!( + controller.state, + ParentIntegrationState::Failed { .. } | ParentIntegrationState::Canceled { .. } + ) && matches!( + run.status, + RunStatus::Succeeded + | RunStatus::Failed + | RunStatus::Cancelled + | RunStatus::PreparationFailed + ) { + return Ok(Some(BTreeMap::new())); + } + if controller.state != ParentIntegrationState::Completed || run.status != RunStatus::Succeeded { return Ok(None); } let Some(final_evidence) = controller.final_evidence.as_ref() else { @@ -860,8 +996,8 @@ fn apply_terminal_capture_bindings( issue.parent_integration = binding.parent_integration; issue.repository_id = (!binding.repository_id.is_empty()).then(|| binding.repository_id.clone()); - issue.execution_run_id = Some(binding.run_id.clone()); - issue.execution_attempt = Some(binding.attempt); + issue.execution_run_id = (!binding.run_id.is_empty()).then(|| binding.run_id.clone()); + issue.execution_attempt = (binding.attempt > 0).then_some(binding.attempt); issue.target_branch = (!binding.target_branch.is_empty()).then(|| binding.target_branch.clone()); issue.target_commit = @@ -2526,6 +2662,26 @@ impl MemoryScopeGrantRegistry { revoked } + pub(crate) fn revoke_issue_generation(&self, issue: &str, generation: &str) -> bool { + let mut state = self.state.write().expect("memory grant registry poisoned"); + let tokens = state + .grants + .iter() + .filter(|(_, grant)| { + grant.issue == issue && grant.checkout_generation.as_deref() == Some(generation) + }) + .map(|(token, _)| token.clone()) + .collect::>(); + let revoked = !tokens.is_empty(); + for token in tokens { + state.grants.remove(&token); + } + if !state.grants.values().any(|grant| grant.issue == issue) { + state.revoked_issues.insert(issue.to_owned()); + } + revoked + } + fn get(&self, token: Option<&str>) -> Option { token.and_then(|token| { self.state @@ -14261,6 +14417,49 @@ Public memory concept. assert!(requires_fresh_conversation); } + #[test] + fn old_generation_cleanup_preserves_a_newer_memory_grant() { + let registry = MemoryScopeGrantRegistry::default(); + let _ = registry.issue_or_refresh_with_lifecycle( + "project-alpha", + "repo-alpha", + BTreeSet::from(["repo-alpha".to_owned()]), + "COE-549", + Some("generation-1".to_owned()), + ); + let (new_token, rotated) = registry.issue_or_refresh_with_lifecycle( + "project-alpha", + "repo-alpha", + BTreeSet::from(["repo-alpha".to_owned()]), + "COE-549", + Some("generation-2".to_owned()), + ); + assert!(rotated); + + assert!(!registry.revoke_issue_generation("COE-549", "generation-1")); + assert!(registry.get(Some(&new_token)).is_some()); + let (same_token, requires_fresh) = registry.issue_or_refresh_with_lifecycle( + "project-alpha", + "repo-alpha", + BTreeSet::from(["repo-alpha".to_owned()]), + "COE-549", + Some("generation-2".to_owned()), + ); + assert_eq!(same_token, new_token); + assert!(!requires_fresh); + + assert!(registry.revoke_issue_generation("COE-549", "generation-2")); + assert!(registry.get(Some(&new_token)).is_none()); + let (_, requires_fresh) = registry.issue_or_refresh_with_lifecycle( + "project-alpha", + "repo-alpha", + BTreeSet::from(["repo-alpha".to_owned()]), + "COE-549", + Some("generation-3".to_owned()), + ); + assert!(requires_fresh); + } + #[test] fn worker_memory_grant_reopen_requires_a_fresh_conversation_after_revocation() { let registry = MemoryScopeGrantRegistry::default(); @@ -14913,6 +15112,7 @@ Public memory concept. updated_at: now, status_detail: None, hooks: Vec::new(), + cleanup_intent: None, }; std::fs::write( workspace.join(".opensymphony/run.json"), @@ -14960,10 +15160,32 @@ Public memory concept. let parent_workspace = workspace_root .path() .join("parents") - .join("COE-554-parent") + .join( + crate::opensymphony_workspace::parent_workspace_key("COE-554", "issue-554") + .expect("parent workspace key"), + ) .join("4"); std::fs::create_dir_all(parent_workspace.join(".opensymphony")) .expect("parent metadata directory"); + let now = Utc::now(); + let parent_manifest = crate::opensymphony_workspace::ParentExecutionManifest { + schema_version: 1, + parent_issue_id: "issue-554".to_owned(), + parent_identifier: "COE-554".to_owned(), + hierarchy_generation: 4, + workspace_path: parent_workspace.clone(), + child_checkout_map: parent_workspace.join(".opensymphony/child-checkouts.json"), + integration_plan: parent_workspace.join(".opensymphony/integration-plan.json"), + evidence_directory: parent_workspace.join(".opensymphony/evidence"), + repositories_directory: parent_workspace.join("repositories"), + created_at: now, + updated_at: now, + }; + std::fs::write( + parent_workspace.join(".opensymphony/parent.json"), + serde_json::to_vec(&parent_manifest).expect("parent manifest JSON"), + ) + .expect("parent manifest"); let parent_envelope = serde_json::from_value(json!({ "parent_issue_id": "issue-554", "parent_identifier": "COE-554", @@ -15208,6 +15430,106 @@ Public memory concept. .is_empty(), "a failed parent run must not publish prepared commits" ); + let mut failed_parent_state = parent_state.clone(); + let failed_controller = failed_parent_state + .parent_integrations + .get_mut(&crate::opensymphony_domain::IssueId::new("issue-554").expect("parent id")) + .expect("parent controller"); + failed_controller.state = + crate::opensymphony_orchestrator::ParentIntegrationState::Failed { + reason: "final verification failed".to_owned(), + }; + failed_controller.final_evidence = None; + std::fs::write( + workspace_root + .path() + .join(".opensymphony-orchestrator-state.json"), + serde_json::to_vec(&failed_parent_state).expect("failed parent controller state"), + ) + .expect("failed parent controller state"); + let failed_parent_bindings = + super::load_terminal_capture_bindings(workspace_root.path(), &["COE-554".to_owned()]) + .expect("terminal failed parent capture binding"); + assert!(failed_parent_bindings["coe-554"].parent_integration); + assert!( + failed_parent_bindings["coe-554"] + .repository_commits + .is_empty(), + "failed parents must route neutral diagnostic capture without claiming commits" + ); + let parent_id = crate::opensymphony_domain::IssueId::new("issue-554").expect("parent id"); + let mut prelaunch_controller = + crate::opensymphony_orchestrator::ParentIntegrationController::new( + parent_id.clone(), + 4, + ) + .expect("pre-launch controller"); + prelaunch_controller + .admit( + "targets:prelaunch", + crate::opensymphony_domain::TimestampMs::new(1), + ) + .expect("pre-launch admission"); + prelaunch_controller + .record_workspace_prepared( + Vec::::new(), + "targets:prelaunch", + crate::opensymphony_domain::TimestampMs::new(2), + ) + .expect("pre-launch workspace"); + prelaunch_controller + .start_attempt_intent( + "integration", + "attempt:prelaunch", + crate::opensymphony_orchestrator::ParentAttemptRoot::ParentRoot, + 1_000, + "targets:prelaunch", + crate::opensymphony_domain::TimestampMs::new(3), + ) + .expect("persisted launch intent"); + prelaunch_controller + .reconcile_restart(false, crate::opensymphony_domain::TimestampMs::new(4)) + .expect("pre-launch restart reconciliation"); + assert!(prelaunch_controller.can_cancel_without_harness()); + prelaunch_controller + .cancel_without_harness( + "tracker canceled before launch", + "targets:prelaunch", + crate::opensymphony_domain::TimestampMs::new(5), + ) + .expect("pre-launch cancellation"); + let prelaunch_state = crate::opensymphony_orchestrator::DurableOrchestratorState { + parent_integrations: BTreeMap::from([(parent_id, prelaunch_controller)]), + ..Default::default() + }; + std::fs::write( + workspace_root + .path() + .join(".opensymphony-orchestrator-state.json"), + serde_json::to_vec(&prelaunch_state).expect("pre-launch parent state"), + ) + .expect("pre-launch parent state"); + std::fs::remove_file(parent_workspace.join(".opensymphony/run.json")) + .expect("remove pre-launch run manifest"); + let prelaunch_bindings = + super::load_terminal_capture_bindings(workspace_root.path(), &["COE-554".to_owned()]) + .expect("pre-launch canceled parent capture binding"); + let prelaunch_binding = &prelaunch_bindings["coe-554"]; + assert!(prelaunch_binding.parent_integration); + assert!(prelaunch_binding.repository_commits.is_empty()); + assert!(prelaunch_binding.run_id.is_empty()); + assert_eq!(prelaunch_binding.attempt, 0); + let mut prelaunch_source = SourceFile { + issues: vec![IssueEvidence { + identifier: "COE-554".to_owned(), + ..IssueEvidence::default() + }], + ..SourceFile::default() + }; + super::apply_terminal_capture_bindings(&mut prelaunch_source, &prelaunch_bindings); + assert!(prelaunch_source.issues[0].parent_integration); + assert!(prelaunch_source.issues[0].execution_run_id.is_none()); + assert!(prelaunch_source.issues[0].execution_attempt.is_none()); parent_run.status = RunStatus::Succeeded; std::fs::write( parent_workspace.join(".opensymphony/run.json"), diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 0d85811c8..28678929c 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -1199,11 +1199,27 @@ fn checkout_env_remove_variables( } fn strict_openhands_cleanup_requires_conversation_store( - strict_checkout: bool, + generation_bound: bool, manifest: &IssueConversationManifest, store: Option<&OpenHandsConversationStorePaths>, ) -> bool { - strict_checkout && !conversation_manifest_is_codex(manifest) && store.is_none() + generation_bound && !conversation_manifest_is_codex(manifest) && store.is_none() +} + +fn run_manifest_proves_no_harness_conversation(run: Option<&RunManifest>) -> bool { + run.is_some_and(|run| { + run.status == RunStatus::PreparationFailed + && run + .runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.as_deref()) + .is_none() + && run + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.as_deref()) + .is_none() + }) } impl TrackerBackend for RuntimeTrackerBackend { @@ -3960,6 +3976,8 @@ impl RuntimeWorkspaceBackend { workspace: &crate::opensymphony_domain::WorkspaceRecord, terminal: bool, force_remove: bool, + cleanup_target: Option<&crate::opensymphony_workspace::CleanupTarget>, + prepare_only: bool, ) -> Result<(), CliWorkspaceError> { if terminal && (force_remove || !self.terminal_cleanup_paths.contains(&workspace.path)) { if self.workspace_has_active_lease(workspace).await? { @@ -3969,21 +3987,47 @@ impl RuntimeWorkspaceBackend { ); return Err(CliWorkspaceError::CleanupDeferred); } - let Some(handle) = self + let handle = self .manager .list_all_workspaces() .await? .into_iter() .find_map(|(handle, _)| { (handle.workspace_path() == workspace.path).then_some(handle) - }) - else { + }); + let Some(handle) = handle else { + if let (Some(scope_grants), Some(target)) = (&self.scope_grants, cleanup_target) { + scope_grants.revoke_issue_generation(&target.identifier, &target.generation); + } + if let Some(target) = cleanup_target { + if prepare_only { + self.manager.prepare_cleanup_target(target).await?; + } else { + self.manager.cleanup_target(target).await?; + } + } return Ok(()); }; if let Some(scope_grants) = &self.scope_grants { - scope_grants.revoke_issue(handle.identifier()); + if let Some(target) = cleanup_target { + scope_grants.revoke_issue_generation(&target.identifier, &target.generation); + } else { + scope_grants.revoke_issue(handle.identifier()); + } } - let removes_workspace = force_remove + if let Some(target) = cleanup_target + && self.manager.cleanup_target_deletion_started(target).await? + { + if prepare_only { + self.manager.prepare_cleanup_target(target).await?; + } else { + self.manager.cleanup_target(target).await?; + } + self.terminal_cleanup_paths.insert(workspace.path.clone()); + return Ok(()); + } + let removes_workspace = cleanup_target.is_some() + || force_remove || self.manager.cleanup_decision(IssueLifecycleState::Terminal) == crate::opensymphony_workspace::CleanupDecision::Remove; let mut cleanup_run_manifest = self.manager.load_run_manifest(&handle).await?; @@ -3994,11 +4038,11 @@ impl RuntimeWorkspaceBackend { ) .await?; let manifest_path = handle.conversation_manifest_path(); - if let Some(raw_manifest) = self + let raw_conversation_manifest = self .manager .read_text_artifact(&handle, &manifest_path) - .await? - { + .await?; + if let Some(raw_manifest) = raw_conversation_manifest { match serde_json::from_str::(&raw_manifest) { Ok(mut manifest) if conversation_manifest_is_codex(&manifest) => { let envelope_compatible = if handle.checkout_generation().is_some() { @@ -4122,7 +4166,7 @@ impl RuntimeWorkspaceBackend { } } } else if strict_openhands_cleanup_requires_conversation_store( - handle.checkout_generation().is_some(), + cleanup_target.is_some() || handle.checkout_generation().is_some(), &manifest, self.openhands_conversation_store.as_ref(), ) { @@ -4145,15 +4189,27 @@ impl RuntimeWorkspaceBackend { %error, "continuing terminal cleanup with invalid conversation manifest" ); - if handle.checkout_generation().is_some() { + if cleanup_target.is_some() || handle.checkout_generation().is_some() { return Err(CliWorkspaceError::ConversationLifecycle(format!( - "strict terminal conversation manifest is malformed: {error}" + "generation-bound terminal conversation manifest is malformed: {error}" ))); } } } + } else if cleanup_target.is_some() + && !run_manifest_proves_no_harness_conversation(cleanup_run_manifest.as_ref()) + { + return Err(CliWorkspaceError::ConversationLifecycle( + "generation-bound terminal conversation manifest is missing".to_owned(), + )); } - if force_remove { + if let Some(target) = cleanup_target { + if prepare_only { + self.manager.prepare_cleanup_target(target).await?; + } else { + self.manager.cleanup_target(target).await?; + } + } else if force_remove { self.manager .cleanup_failed_terminal_workspace(&handle) .await?; @@ -4441,6 +4497,45 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { Ok(state.active_for(&resource)) } + async fn cleanup_target_for_resource( + &mut self, + resource: &LeaseResource, + outcome: crate::opensymphony_workspace::CleanupTerminalOutcome, + ) -> Result, Self::Error> { + Ok(self + .manager + .list_all_workspaces() + .await? + .into_iter() + .find_map(|(handle, manifest)| { + let repository_matches = manifest + .repository_binding + .as_ref() + .and_then(RepositoryBindingOutcome::repository_id) + == Some(&resource.repository_id); + (manifest.issue_id == resource.issue_id.as_str() + && handle.checkout_generation() == Some(&resource.checkout_generation) + && repository_matches) + .then(|| crate::opensymphony_workspace::CleanupTarget { + issue_id: manifest.issue_id, + identifier: manifest.identifier, + workspace: crate::opensymphony_domain::WorkspaceRecord { + path: handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(handle.workspace_key().to_owned()) + .expect("managed workspace keys are already validated"), + created_now: false, + created_at: Some(datetime_to_timestamp_ms(manifest.created_at)), + updated_at: Some(datetime_to_timestamp_ms(manifest.updated_at)), + last_seen_tracker_refresh_at: manifest + .last_seen_tracker_refresh_at + .map(datetime_to_timestamp_ms), + }, + generation: resource.checkout_generation.clone(), + outcome, + }) + })) + } + async fn parent_workspace_targets( &mut self, issue: &NormalizedIssue, @@ -4694,7 +4789,7 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { workspace: &crate::opensymphony_domain::WorkspaceRecord, terminal: bool, ) -> Result<(), Self::Error> { - self.cleanup_workspace_with_policy(workspace, terminal, false) + self.cleanup_workspace_with_policy(workspace, terminal, false, None, false) .await } @@ -4702,7 +4797,7 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { &mut self, workspace: &crate::opensymphony_domain::WorkspaceRecord, ) -> Result<(), Self::Error> { - self.cleanup_workspace_with_policy(workspace, true, true) + self.cleanup_workspace_with_policy(workspace, true, true, None, false) .await } @@ -4710,7 +4805,23 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { &mut self, workspace: &crate::opensymphony_domain::WorkspaceRecord, ) -> Result<(), Self::Error> { - self.cleanup_workspace_with_policy(workspace, true, true) + self.cleanup_workspace_with_policy(workspace, true, true, None, false) + .await + } + + async fn cleanup_generation( + &mut self, + target: &crate::opensymphony_workspace::CleanupTarget, + ) -> Result<(), Self::Error> { + self.cleanup_workspace_with_policy(&target.workspace, true, true, Some(target), false) + .await + } + + async fn prepare_cleanup_generation( + &mut self, + target: &crate::opensymphony_workspace::CleanupTarget, + ) -> Result<(), Self::Error> { + self.cleanup_workspace_with_policy(&target.workspace, true, true, Some(target), true) .await } @@ -6003,7 +6114,12 @@ impl RuntimeWorkerBackend { attempt: scoped.attempt, checkout_generation: runtime_envelope .as_ref() - .map(|envelope| envelope.checkout_generation.clone()), + .map(|envelope| envelope.checkout_generation.clone()) + .or_else(|| { + parent_execution.as_ref().map(|parent| { + format!("parent:{}", parent.manifest.hierarchy_generation) + }) + }), target_commit: scoped.target_commit.clone(), checkout_head: scoped.checkout_head.clone(), visibility: scoped.visibility, @@ -10680,6 +10796,7 @@ mod tests { updated_at: now, status_detail: None, hooks: Vec::new(), + cleanup_intent: None, }; let mut conversation_manifest = sample_conversation_manifest("legacy-openhands"); conversation_manifest.prepared_run_id = Some(run_manifest.run_id.clone()); @@ -10756,6 +10873,7 @@ mod tests { updated_at: now, status_detail: None, hooks: Vec::new(), + cleanup_intent: None, }; let mut conversation_manifest = sample_conversation_manifest("conv-pending"); conversation_manifest.workflow_prompt_seeded = false; @@ -10840,6 +10958,7 @@ mod tests { updated_at: now, status_detail: None, hooks: Vec::new(), + cleanup_intent: None, }; let mut conversation_manifest = sample_conversation_manifest("conv-unsent-prompt"); conversation_manifest.workflow_prompt_seeded = true; @@ -11792,6 +11911,265 @@ mod tests { assert!(!ensured.handle.workspace_path().exists()); } + #[cfg(unix)] + #[tokio::test] + async fn runtime_parent_cleanup_archives_codex_before_preparing_nested_root_removal() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspaces"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let mut issue = sample_terminal_issue(); + issue.id = IssueId::new("parent-cleanup").expect("parent id"); + issue.identifier = IssueIdentifier::new("COE-PARENT-CLEANUP").expect("parent identifier"); + let parent = workspace_manager + .prepare_parent_execution_root(&issue_descriptor(&issue), 9, Vec::new()) + .await + .expect("nested parent root should be prepared"); + let mut manifest = sample_conversation_manifest("fake-thread"); + manifest.issue_id = issue.id.clone(); + manifest.identifier = issue.identifier.clone(); + manifest.transport_target = Some(CODEX_APP_SERVER_KIND.to_owned()); + manifest.runtime_contract_version = Some(CODEX_APP_SERVER_CONTRACT.to_owned()); + workspace_manager + .write_json_artifact( + &parent.handle, + &parent.handle.conversation_manifest_path(), + &manifest, + ) + .await + .expect("parent conversation manifest should persist"); + let log_path = tempdir.path().join("fake-parent-cleanup-codex.log"); + let fake_codex = tempdir.path().join("fake-parent-cleanup-codex"); + write_fake_codex_child(&fake_codex, &log_path); + let target = crate::opensymphony_workspace::CleanupTarget { + issue_id: issue.id.to_string(), + identifier: issue.identifier.to_string(), + workspace: crate::opensymphony_domain::WorkspaceRecord { + path: parent.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(parent.handle.workspace_key().to_owned()) + .expect("parent workspace key"), + created_now: false, + created_at: None, + updated_at: None, + last_seen_tracker_refresh_at: None, + }, + generation: "parent:9".to_owned(), + outcome: crate::opensymphony_workspace::CleanupTerminalOutcome::Succeeded, + }; + let mut backend = RuntimeWorkspaceBackend::new(Arc::clone(&workspace_manager), &workflow); + backend.codex_bin = fake_codex.to_string_lossy().into_owned(); + + backend + .prepare_cleanup_generation(&target) + .await + .expect("parent archive fence and cleanup preparation should succeed"); + + assert!(parent.handle.workspace_path().is_dir()); + let log = fs::read_to_string(log_path).expect("Codex lifecycle log should exist"); + assert!(log.contains(r#""method":"thread/archive""#)); + + workspace_manager + .write_text_artifact( + &parent.handle, + &parent.handle.conversation_manifest_path(), + "{\"malformed\":true}", + ) + .await + .expect("malformed parent conversation manifest should persist"); + let error = backend + .prepare_cleanup_generation(&target) + .await + .expect_err("generation-bound parent cleanup must fail closed"); + assert!( + error + .to_string() + .contains("generation-bound terminal conversation manifest is malformed") + ); + assert!(parent.handle.workspace_path().is_dir()); + + let mut openhands_manifest = + sample_conversation_manifest("11111111-1111-4111-8111-111111111111"); + openhands_manifest.issue_id = issue.id.clone(); + openhands_manifest.identifier = issue.identifier.clone(); + workspace_manager + .write_json_artifact( + &parent.handle, + &parent.handle.conversation_manifest_path(), + &openhands_manifest, + ) + .await + .expect("OpenHands parent conversation manifest should persist"); + let error = backend + .prepare_cleanup_generation(&target) + .await + .expect_err("generation-bound parent cleanup requires its conversation store"); + assert!( + error + .to_string() + .contains("remote conversation store is unavailable") + ); + assert!(parent.handle.workspace_path().is_dir()); + + fs::remove_file(parent.handle.conversation_manifest_path()) + .expect("parent conversation manifest should be removable for recovery test"); + let error = backend + .prepare_cleanup_generation(&target) + .await + .expect_err("launched generation-bound parent cleanup needs conversation evidence"); + assert!( + error + .to_string() + .contains("generation-bound terminal conversation manifest is missing") + ); + assert!(parent.handle.workspace_path().is_dir()); + } + + #[tokio::test] + async fn runtime_cleanup_resumes_exact_tombstone_when_checkout_run_manifest_is_missing() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspaces"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let issue = sample_terminal_issue(); + let ensured = workspace_manager + .ensure(&issue_descriptor(&issue)) + .await + .expect("workspace should be ensured"); + let binding = crate::opensymphony_domain::RepositoryBinding { + alias: "test".to_owned(), + repository: crate::opensymphony_domain::RepositoryIdentity { + id: CanonicalRepositoryId::new("github:repository:test").expect("repository id"), + safe_remote_fingerprint: + crate::opensymphony_domain::SafeRemoteFingerprint::from_remote( + "github", + Some("test"), + "example/test", + ) + .expect("remote fingerprint"), + }, + config_generation: "config-1".to_owned(), + inventory_generation: "inventory-1".to_owned(), + }; + let mut issue_manifest = workspace_manager + .load_issue_manifest(&ensured.handle) + .await + .expect("issue manifest should load") + .expect("issue manifest should exist"); + issue_manifest.repository_binding = + Some(RepositoryBindingOutcome::Resolved(binding.clone())); + workspace_manager + .write_issue_manifest(&ensured.handle, &issue_manifest) + .await + .expect("repository-bound issue manifest should persist"); + let now = chrono::Utc::now(); + let checkout = crate::opensymphony_workspace::CheckoutManifest { + schema_version: 1, + generation: "checkout-generation-1".to_owned(), + issue_id: issue.id.to_string(), + identifier: issue.identifier.to_string(), + run_id: "checkout-run-1".to_owned(), + sanitized_workspace_key: ensured.handle.workspace_key().to_owned(), + workspace_path: ensured.handle.workspace_path().to_path_buf(), + repository_binding: binding, + policy_generation: "policy-1".to_owned(), + review_profile: String::new(), + review_provider: String::new(), + review_policy_generation: String::new(), + remote_fingerprint: "sha256:test".to_owned(), + target_branch: "develop".to_owned(), + target_commit: "abc123".to_owned(), + current_branch: "feat/test".to_owned(), + head: "abc123".to_owned(), + shallow: false, + clean: true, + instruction: crate::opensymphony_workspace::InstructionProvenance { + path: PathBuf::from("AGENTS.md"), + content_hash: "sha256:instructions".to_owned(), + source_commit: "abc123".to_owned(), + source: "AGENTS.md".to_owned(), + native_discovery_paths: Vec::new(), + native_discovery_hashes: BTreeMap::new(), + }, + created_at: now, + verified_at: now, + quarantined: false, + quarantine_reason: None, + }; + workspace_manager + .write_json_artifact( + &ensured.handle, + &ensured.handle.checkout_manifest_path(), + &checkout, + ) + .await + .expect("checkout manifest should persist"); + let target = crate::opensymphony_workspace::CleanupTarget { + issue_id: issue.id.to_string(), + identifier: issue.identifier.to_string(), + workspace: crate::opensymphony_domain::WorkspaceRecord { + path: ensured.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(ensured.handle.workspace_key().to_owned()) + .expect("workspace key"), + created_now: false, + created_at: None, + updated_at: None, + last_seen_tracker_refresh_at: None, + }, + generation: checkout.generation.clone(), + outcome: crate::opensymphony_workspace::CleanupTerminalOutcome::Succeeded, + }; + let issue_bytes = serde_json::to_vec_pretty(&issue_manifest).expect("encode issue"); + let checkout_bytes = serde_json::to_vec_pretty(&checkout).expect("encode checkout"); + workspace_manager + .cleanup_target(&target) + .await + .expect("initial cleanup should create a receipt"); + let tombstone_path = fs::read_dir(workspace_root.join(".opensymphony-cleanup-tombstones")) + .expect("tombstone directory") + .next() + .expect("tombstone entry") + .expect("tombstone path") + .path(); + let mut tombstone: serde_json::Value = serde_json::from_slice( + &fs::read(&tombstone_path).expect("completed tombstone should be readable"), + ) + .expect("tombstone should decode"); + tombstone["deleted_at"] = serde_json::Value::Null; + fs::write( + &tombstone_path, + serde_json::to_vec_pretty(&tombstone).expect("encode incomplete tombstone"), + ) + .expect("incomplete tombstone should persist"); + fs::create_dir_all(ensured.handle.metadata_dir()).expect("partial workspace should exist"); + fs::write(ensured.handle.issue_manifest_path(), issue_bytes) + .expect("partial issue manifest should persist"); + fs::write(ensured.handle.checkout_manifest_path(), checkout_bytes) + .expect("partial checkout manifest should persist"); + let mut conversation = sample_conversation_manifest("stale-thread"); + conversation.transport_target = Some(CODEX_APP_SERVER_KIND.to_owned()); + conversation.runtime_contract_version = Some(CODEX_APP_SERVER_CONTRACT.to_owned()); + fs::write( + ensured.handle.conversation_manifest_path(), + serde_json::to_vec_pretty(&conversation).expect("encode conversation"), + ) + .expect("partial conversation manifest should persist"); + assert!(!ensured.handle.run_manifest_path().exists()); + let mut backend = RuntimeWorkspaceBackend::new(workspace_manager, &workflow); + + backend + .cleanup_generation(&target) + .await + .expect("exact deletion-started tombstone should bypass missing run metadata"); + + assert!(!ensured.handle.workspace_path().exists()); + } + #[tokio::test] async fn runtime_failed_cleanup_retires_openhands_before_removal() { let tempdir = TempDir::new().expect("tempdir should exist"); @@ -13697,6 +14075,49 @@ mod tests { assert_eq!(recovered.workspace.path, ensured.handle.workspace_path()); } + #[tokio::test] + async fn recover_workspaces_discovers_completed_nested_parent_roots() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let mut issue = sample_terminal_issue(); + issue.id = IssueId::new("parent-recovery").expect("parent id"); + issue.identifier = IssueIdentifier::new("COE-PARENT-RECOVERY").expect("parent identifier"); + let parent = workspace_manager + .prepare_parent_execution_root(&issue_descriptor(&issue), 7, Vec::new()) + .await + .expect("nested parent root should be prepared"); + let mut run = workspace_manager + .start_run( + &parent.handle, + &RunDescriptor::new("parent-run-recovery", 1), + ) + .await + .expect("parent run should start"); + run.status = RunStatus::Succeeded; + workspace_manager + .write_run_manifest(&parent.handle, &run) + .await + .expect("completed parent run should persist"); + + let mut backend = RuntimeWorkspaceBackend::new(workspace_manager, &workflow); + let recoveries = backend + .recover_workspaces() + .await + .expect("nested parent recovery should succeed"); + + let recovered = recoveries + .iter() + .find(|record| record.issue.id == issue.id) + .expect("nested parent should be returned to the scheduler"); + assert_eq!(recovered.workspace.path, parent.handle.workspace_path()); + assert!(recovered.successful_run); + } + #[tokio::test] async fn recover_workspaces_reattaches_ambiguous_prepared_openhands_runs() { let tempdir = TempDir::new().expect("tempdir should exist"); diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index 2430ff94a..aadf01e19 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -1254,8 +1254,10 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { }, }; let startup_terminal_issues = terminal_issue_identifiers(&bootstrap_snapshot); + let recovered_completed_parent_captures = scheduler.completed_subtree_cleanup_identifiers(); let mut auto_capture_completed_issues = initial_auto_capture_completed_issues( &startup_terminal_issues, + &recovered_completed_parent_captures, runtime.memory.auto_capture, ); push_recent_event( @@ -1372,11 +1374,35 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { } Err(error) => Err(error), }; - mark_auto_capture_completed( - &mut auto_capture_completed_issues, - &auto_capture_candidates, - &auto_capture_result, - ); + let cleanup_acknowledged = match &auto_capture_result { + Ok(report) if report.workflow_completed() => { + let completed = if report.completed_issue_keys.is_empty() + && report.warnings.is_empty() + { + auto_capture_candidates.clone() + } else { + report.completed_issue_keys.clone() + }; + match scheduler + .acknowledge_terminal_capture(&completed, observed_at) + .await + { + Ok(()) => true, + Err(error) => { + warn!(%error, "failed to persist terminal capture acknowledgement; cleanup will wait for retry"); + false + } + } + } + _ => true, + }; + if cleanup_acknowledged { + mark_auto_capture_completed( + &mut auto_capture_completed_issues, + &auto_capture_candidates, + &auto_capture_result, + ); + } publish_auto_capture_event( auto_capture_result, &snapshot, @@ -2000,10 +2026,11 @@ fn auto_capture_candidates( fn initial_auto_capture_completed_issues( startup_terminal_issues: &BTreeSet, + recovered_completed_parent_captures: &BTreeSet, auto_capture_enabled: bool, ) -> BTreeSet { if auto_capture_enabled { - BTreeSet::new() + recovered_completed_parent_captures.clone() } else { startup_terminal_issues.clone() } @@ -2520,13 +2547,27 @@ mod tests { #[test] fn startup_terminal_issues_retry_capture_after_daemon_restart() { let terminal = issue_set(&["COE-1", "COE-2"]); - assert!(initial_auto_capture_completed_issues(&terminal, true).is_empty()); + assert!( + initial_auto_capture_completed_issues(&terminal, &BTreeSet::new(), true).is_empty() + ); assert_eq!( - initial_auto_capture_completed_issues(&terminal, false), + initial_auto_capture_completed_issues(&terminal, &BTreeSet::new(), false), terminal ); } + #[test] + fn startup_preserves_durable_completed_parent_capture_markers() { + let terminal = issue_set(&["COE-PARENT", "COE-LEAF"]); + let recovered = issue_set(&["COE-PARENT"]); + + let mut completed = initial_auto_capture_completed_issues(&terminal, &recovered, true); + assert_eq!( + auto_capture_candidates(&terminal, &mut completed, true), + vec!["COE-LEAF".to_owned()] + ); + } + #[test] fn auto_capture_candidates_forget_reopened_issues() { let current = issue_set(&["COE-2"]); diff --git a/crates/opensymphony-domain/src/lib.rs b/crates/opensymphony-domain/src/lib.rs index 16382a66d..6c76a8e40 100644 --- a/crates/opensymphony-domain/src/lib.rs +++ b/crates/opensymphony-domain/src/lib.rs @@ -1231,6 +1231,21 @@ mod tests { assert_eq!(execution.workspace(), Some(&refreshed_workspace)); } + #[test] + fn attach_workspace_accepts_a_nested_parent_generation_identity() { + let issue = sample_issue(); + let parent_key = "parent-COE-260-0123456789abcdef"; + let workspace = WorkspaceRecord { + path: PathBuf::from(format!("/tmp/workspaces/parents/{parent_key}/4")), + workspace_key: must(WorkspaceKey::new(parent_key)), + ..sample_workspace() + }; + let mut execution = IssueExecution::new(issue, ts(30)); + + must(execution.attach_workspace(workspace.clone())); + assert_eq!(execution.workspace(), Some(&workspace)); + } + #[test] fn retry_queued_execution_adopts_same_key_replacement_generation() { let mut execution = claimed_execution(); diff --git a/crates/opensymphony-domain/src/state_machine.rs b/crates/opensymphony-domain/src/state_machine.rs index 1c9dbf4f5..50d7d2b38 100644 --- a/crates/opensymphony-domain/src/state_machine.rs +++ b/crates/opensymphony-domain/src/state_machine.rs @@ -819,17 +819,34 @@ fn workspace_path_matches_key(path: &Path, key: &WorkspaceKey) -> bool { .to_str() .and_then(|name| name.strip_prefix(&format!("{}--", key.as_str()))) .is_some_and(|generation| !generation.is_empty()) + || (name + .to_str() + .and_then(|generation| generation.parse::().ok()) + .is_some_and(|generation| generation > 0) + && path + .parent() + .and_then(Path::file_name) + .is_some_and(|parent| parent == OsStr::new(key.as_str())) + && path + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .is_some_and(|parent| parent == OsStr::new("parents"))) }) } fn workspace_key_matches_issue(actual: &WorkspaceKey, expected: &WorkspaceKey) -> bool { actual == expected - || actual - .as_str() - .strip_prefix(&format!("{}-", expected.as_str())) - .is_some_and(|digest| { + || [ + format!("{}-", expected.as_str()), + format!("parent-{}-", expected.as_str()), + ] + .iter() + .any(|prefix| { + actual.as_str().strip_prefix(prefix).is_some_and(|digest| { digest.len() == 16 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) }) + }) } fn comparable_workspace_path(path: &Path) -> PathBuf { diff --git a/crates/opensymphony-orchestrator/src/hierarchy.rs b/crates/opensymphony-orchestrator/src/hierarchy.rs index 3706ee08b..abdfaddbe 100644 --- a/crates/opensymphony-orchestrator/src/hierarchy.rs +++ b/crates/opensymphony-orchestrator/src/hierarchy.rs @@ -711,6 +711,27 @@ impl DurableOrchestratorState { { return Err("durable parent integration controller is inconsistent".to_owned()); } + if let Some(cleanup) = controller.subtree_cleanup.as_ref() { + let mut resources = BTreeSet::new(); + if cleanup.hierarchy_generation != controller.hierarchy_generation + || cleanup.parent_root.resource.is_some() + || cleanup.descendants.iter().any(|target| { + target.resource.as_ref().is_none_or(|resource| { + resource.checkout_generation != target.cleanup.generation + || !resources.insert(resource.clone()) + }) + }) + || (cleanup.status == super::ParentSubtreeCleanupStatus::Completed + && (cleanup.parent_root.prepared_at.is_none() + || cleanup.parent_root.cleaned_at.is_none() + || cleanup + .descendants + .iter() + .any(|target| target.cleaned_at.is_none()))) + { + return Err("durable parent subtree cleanup intent is inconsistent".to_owned()); + } + } let mut repair_ids = BTreeSet::new(); let mut repair_numbers = BTreeSet::new(); for repair in &controller.repair_attempts { @@ -830,6 +851,33 @@ impl DurableOrchestratorState { self.release_parent_leases_inner(parent_id, released_at, false) } + pub fn release_parent_resource_leases( + &mut self, + parent_id: &IssueId, + resource: &LeaseResource, + released_at: u64, + ) -> bool { + let ancestor_owner = LeaseOwner::ancestor(parent_id); + let repair_owner = LeaseOwner::repair(parent_id); + let review_prefix = format!("review:{parent_id}:"); + let mut released = false; + for lease in &mut self.leases { + if lease.active() + && lease.resource == *resource + && (lease.owner == ancestor_owner + || lease.owner == repair_owner + || (lease.kind == LeaseKind::Review + && lease.owner.kind == "review" + && lease.owner.id.starts_with(&review_prefix))) + { + lease.released_at = Some(released_at); + released = true; + } + } + self.compact_lease_history(); + released + } + fn release_parent_leases_inner( &mut self, parent_id: &IssueId, @@ -867,6 +915,18 @@ impl DurableOrchestratorState { self.resources_for_ancestor_owner(&owner, current_generation, current_subtree) } + pub fn descendant_resources_for_generation( + &self, + parent_id: &IssueId, + hierarchy_generation: u64, + ) -> Vec { + self.resources_for_ancestor_owner( + &LeaseOwner::ancestor(parent_id), + Some(hierarchy_generation), + None, + ) + } + pub fn ancestor_resources_for_child( &self, parent_id: &IssueId, @@ -904,7 +964,7 @@ impl DurableOrchestratorState { .is_none_or(|generation| lease.hierarchy_generation == generation) && current_subtree .as_ref() - .is_some_and(|subtree| subtree.contains(&lease.resource.issue_id)) + .is_none_or(|subtree| subtree.contains(&lease.resource.issue_id)) { resources .entry(lease.resource.clone()) @@ -1323,9 +1383,13 @@ impl DurableOrchestratorState { } pub fn active_for(&self, resource: &LeaseResource) -> bool { + self.active_for_at(resource, current_epoch_millis()) + } + + pub fn active_for_at(&self, resource: &LeaseResource, now: u64) -> bool { self.leases .iter() - .any(|lease| lease.active() && lease.resource == *resource) + .any(|lease| lease.active_at(now) && lease.resource == *resource) } pub fn has_ancestor_edge(&self, child_id: &IssueId) -> bool { @@ -2016,6 +2080,15 @@ mod tests { expires_at: None, released_at: None, }, + LeaseRecord { + kind: LeaseKind::DiagnosticHold, + resource: resource.clone(), + owner: LeaseOwner::diagnostic("operator"), + hierarchy_generation: 2, + acquired_at: 3, + expires_at: Some(10), + released_at: None, + }, ]) .expect("nested leases should acquire"); @@ -2023,12 +2096,22 @@ mod tests { state.descendant_resources_for(&intermediate), vec![resource.clone()] ); - assert!(state.release_parent_leases(&intermediate, 4)); + assert!(state.release_parent_resource_leases(&intermediate, &resource, 4)); assert!( state.leases.iter().any(|lease| { lease.owner == LeaseOwner::ancestor(&higher) && lease.active_at(4) }) ); + assert!(state.leases.iter().any(|lease| { + lease.owner == LeaseOwner::diagnostic("operator") && lease.active_at(4) + })); + assert!(state.active_for_at(&resource, 4)); + assert!( + !state.leases.iter().any(|lease| { + lease.owner == LeaseOwner::diagnostic("operator") && lease.active_at(10) + }), + "diagnostic holds expire at their bounded deadline" + ); assert!( state .leases diff --git a/crates/opensymphony-orchestrator/src/lib.rs b/crates/opensymphony-orchestrator/src/lib.rs index 3fe2e5c8a..1ebddf241 100644 --- a/crates/opensymphony-orchestrator/src/lib.rs +++ b/crates/opensymphony-orchestrator/src/lib.rs @@ -27,8 +27,9 @@ pub use parent_integration::{ ParentIntegrationState, ParentProviderOperation, ParentProviderOperationKind, ParentRepairAttempt, ParentRepairPolicy, ParentRepairProviderSnapshot, ParentRepairStatus, ParentRepositoryTarget, ParentResourceReceipt, ParentResourceStatus, ParentRetryClassification, - ParentReviewFeedback, ParentSideEffectIntent, ParentSideEffectReceipt, ParentTransition, - ParentVerificationAttempt, parent_command_identity, + ParentReviewFeedback, ParentSideEffectIntent, ParentSideEffectReceipt, + ParentSubtreeCleanupIntent, ParentSubtreeCleanupStatus, ParentSubtreeCleanupTarget, + ParentTransition, ParentVerificationAttempt, parent_command_identity, }; pub use scheduler::{ HarnessRouteDecision, RecoveredRun, RecoveryRecord, RetryExhaustionRecord, RetryPendingRecord, diff --git a/crates/opensymphony-orchestrator/src/parent_integration.rs b/crates/opensymphony-orchestrator/src/parent_integration.rs index 6edaf734d..5f958bdd9 100644 --- a/crates/opensymphony-orchestrator/src/parent_integration.rs +++ b/crates/opensymphony-orchestrator/src/parent_integration.rs @@ -3,10 +3,13 @@ use std::{ path::{Path, PathBuf}, }; +use super::hierarchy::LeaseResource; use crate::opensymphony_domain::{ CanonicalRepositoryId, IssueId, ParentVerificationEvidence, TimestampMs, }; -use crate::opensymphony_workspace::redact_runtime_diagnostic; +use crate::opensymphony_workspace::{ + CleanupTarget, CleanupTerminalOutcome, redact_runtime_diagnostic, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -424,6 +427,41 @@ pub struct ParentFinalEvidence { pub recorded_at: TimestampMs, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParentSubtreeCleanupStatus { + Pending, + Removing, + Retained, + Completed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentSubtreeCleanupTarget { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + pub cleanup: CleanupTarget, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prepared_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleaned_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParentSubtreeCleanupIntent { + pub hierarchy_generation: u64, + pub capture_acknowledged_at: TimestampMs, + pub requested_at: TimestampMs, + pub outcome: CleanupTerminalOutcome, + pub status: ParentSubtreeCleanupStatus, + pub parent_root: ParentSubtreeCleanupTarget, + pub descendants: Vec, + #[serde(default)] + pub retry_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ParentIntegrationController { pub parent_id: IssueId, @@ -449,6 +487,8 @@ pub struct ParentIntegrationController { pub repair_attempts: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub final_evidence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subtree_cleanup: Option, } #[derive(Debug, Clone, PartialEq, Eq, Error)] @@ -513,6 +553,7 @@ impl ParentIntegrationController { attempts: Vec::new(), repair_attempts: Vec::new(), final_evidence: None, + subtree_cleanup: None, }) } @@ -2233,6 +2274,7 @@ impl ParentIntegrationController { pub fn can_cancel_without_harness(&self) -> bool { self.current_attempt_id().is_none() + && !self.has_unreconciled_provider_operation() && self.attempts.iter().all(|attempt| { let cleanup_succeeded = attempt .cleanup @@ -2250,6 +2292,15 @@ impl ParentIntegrationController { }) } + pub fn has_unreconciled_provider_operation(&self) -> bool { + self.repair_attempts.iter().any(|repair| { + repair + .operations + .iter() + .any(|operation| operation.receipt.is_none() || operation.completed_at.is_none()) + }) + } + pub fn has_unreconciled_harness(&self) -> bool { self.attempts.iter().any(|attempt| { attempt.conversation_id.is_some() @@ -3499,6 +3550,12 @@ mod tests { let mut controller = controller(); let repair_id = begin_repair(&mut controller); + assert!(controller.has_unreconciled_provider_operation()); + assert!( + !controller.can_cancel_without_harness(), + "a provider intent without a receipt must fence terminal cleanup" + ); + assert!(matches!( controller.begin_provider_operation( &repair_id, @@ -3525,6 +3582,7 @@ mod tests { TimestampMs::new(12), ) .expect("create intent"); + assert!(!controller.can_cancel_without_harness()); assert_eq!( controller .begin_provider_operation( @@ -3544,6 +3602,17 @@ mod tests { .len(), 2 ); + controller + .record_provider_operation( + &repair_id, + &create_key, + "created", + None, + TimestampMs::new(14), + ) + .expect("create receipt"); + assert!(!controller.has_unreconciled_provider_operation()); + assert!(controller.can_cancel_without_harness()); } #[test] diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index caa0e49cb..fbcb0fa0c 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -20,7 +20,8 @@ use crate::opensymphony_domain::{ use crate::opensymphony_gateway_schema::capability::{HarnessCapability, HarnessKind}; use crate::opensymphony_workflow::{ResolvedWorkflow, RoutingConfig}; use crate::opensymphony_workspace::{ - checkout_workspace_key, redact_runtime_diagnostic, sanitize_workspace_key, + CleanupTarget, CleanupTerminalOutcome, checkout_workspace_key, redact_runtime_diagnostic, + sanitize_workspace_key, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -37,7 +38,8 @@ use super::{ LeaseResource, ParentAttemptRoot, ParentAttemptStatus, ParentCleanupReceipt, ParentCleanupStatus, ParentEligibilityEvidence, ParentIntegrationController, ParentIntegrationError, ParentProviderOperationKind, ParentRepairAttempt, ParentRepairPolicy, - ParentRepairStatus, ParentRepositoryTarget, + ParentRepairStatus, ParentRepositoryTarget, ParentSubtreeCleanupIntent, + ParentSubtreeCleanupStatus, ParentSubtreeCleanupTarget, }; const DISABLED_STALL_TIMEOUT_MS: u64 = u64::MAX / 4; @@ -470,6 +472,14 @@ pub trait WorkspaceBackend { Ok(false) } + async fn cleanup_target_for_resource( + &mut self, + _resource: &LeaseResource, + _outcome: CleanupTerminalOutcome, + ) -> Result, Self::Error> { + Ok(None) + } + async fn parent_workspace_targets( &mut self, _issue: &NormalizedIssue, @@ -555,6 +565,17 @@ pub trait WorkspaceBackend { self.cleanup_workspace(workspace, true).await } + async fn cleanup_generation(&mut self, target: &CleanupTarget) -> Result<(), Self::Error> { + self.cleanup_workspace(&target.workspace, true).await + } + + async fn prepare_cleanup_generation( + &mut self, + _target: &CleanupTarget, + ) -> Result<(), Self::Error> { + Ok(()) + } + async fn persist_retry_count( &mut self, _workspace: &WorkspaceRecord, @@ -2038,6 +2059,16 @@ where self.executions.get(issue_id) } + pub fn completed_subtree_cleanup_identifiers(&self) -> BTreeSet { + self.hierarchy_state + .parent_integrations + .values() + .filter_map(|controller| controller.subtree_cleanup.as_ref()) + .filter(|cleanup| cleanup.status == ParentSubtreeCleanupStatus::Completed) + .map(|cleanup| cleanup.parent_root.cleanup.identifier.clone()) + .collect() + } + pub fn snapshot(&self, generated_at: TimestampMs) -> OrchestratorSnapshot { let mut issues = self .executions @@ -2132,14 +2163,28 @@ where self.load_recovery_state().await?; self.flush_pending_retry_persistence().await?; self.flush_pending_retry_exhaustion_persistence().await?; + let retain_failed = self.workspace.retain_failed_workspaces(); + let subtree_cleanup_pending = self + .hierarchy_state + .parent_integrations + .values() + .filter_map(|controller| controller.subtree_cleanup.as_ref()) + .any(|cleanup| { + matches!( + cleanup.status, + ParentSubtreeCleanupStatus::Pending | ParentSubtreeCleanupStatus::Removing + ) || (cleanup.status == ParentSubtreeCleanupStatus::Retained + && (cleanup.outcome != CleanupTerminalOutcome::Failed || !retain_failed)) + }); self.expire_linear_cooldown(observed_at); let mut pre_update_full_snapshot = if !self.linear_cooldown_active(observed_at) - && due( - self.last_full_detail_refresh_at, - FULL_DETAIL_REFRESH_INTERVAL_MS, - observed_at, - ) { + && (subtree_cleanup_pending + || due( + self.last_full_detail_refresh_at, + FULL_DETAIL_REFRESH_INTERVAL_MS, + observed_at, + )) { if let Some(tracker_snapshot) = self.load_tracker_snapshot(observed_at).await? { self.record_full_detail_refresh(observed_at); let hierarchy_changed = self.reconcile_hierarchy_snapshots(&tracker_snapshot)?; @@ -2219,6 +2264,13 @@ where .await?; self.reconcile_tracker_state(tracker_snapshot, observed_at) .await?; + let active_issue_ids = tracker_snapshot + .active + .iter() + .map(|issue| issue.id.clone()) + .collect::>(); + self.reconcile_parent_subtree_cleanup(observed_at, &active_issue_ids) + .await?; dispatch_candidates = Some(DispatchCandidates::Full { issues: tracker_snapshot.active.clone(), reachable_child_edges: self @@ -2239,6 +2291,13 @@ where .await?; self.reconcile_tracker_state(&tracker_snapshot, observed_at) .await?; + let active_issue_ids = tracker_snapshot + .active + .iter() + .map(|issue| issue.id.clone()) + .collect::>(); + self.reconcile_parent_subtree_cleanup(observed_at, &active_issue_ids) + .await?; dispatch_candidates = Some(DispatchCandidates::Full { reachable_child_edges: self .required_child_edges_in_tracker_snapshot(&tracker_snapshot), @@ -2293,6 +2352,446 @@ where Ok(self.snapshot(observed_at)) } + pub async fn acknowledge_terminal_capture( + &mut self, + issue_identifiers: &[String], + observed_at: TimestampMs, + ) -> Result<(), SchedulerError> { + let captured = issue_identifiers.iter().collect::>(); + let parent_ids = self + .executions + .iter() + .filter(|(_, execution)| { + captured.contains(&execution.issue().identifier.to_string()) + && self + .hierarchy_state + .parent_integrations + .contains_key(&execution.issue().id) + }) + .map(|(issue_id, _)| issue_id.clone()) + .collect::>(); + let mut intents = Vec::new(); + for parent_id in parent_ids { + let Some(execution) = self.executions.get(&parent_id).cloned() else { + continue; + }; + let Some(parent_workspace) = execution.workspace().cloned() else { + continue; + }; + let Some(controller) = self + .hierarchy_state + .parent_integrations + .get(&parent_id) + .cloned() + else { + continue; + }; + if controller.subtree_cleanup.is_some() { + continue; + } + if controller.has_unreconciled_harness() { + return Err(SchedulerError::Workspace { + detail: format!( + "captured parent {parent_id} still has an unreconciled harness cleanup fence" + ), + }); + } + if controller.has_unreconciled_provider_operation() { + return Err(SchedulerError::Workspace { + detail: format!( + "captured parent {parent_id} still has an unreconciled provider operation cleanup fence" + ), + }); + } + let outcome = match controller.state { + super::ParentIntegrationState::Completed => { + if controller.final_evidence.is_none() + || controller.repair_attempts.iter().any(|repair| { + repair.status != ParentRepairStatus::Completed + || repair.operations.iter().any(|operation| { + operation.receipt.is_none() || operation.completed_at.is_none() + }) + }) + { + return Err(SchedulerError::Workspace { + detail: format!( + "captured parent {parent_id} is missing final evidence or repair receipts" + ), + }); + } + CleanupTerminalOutcome::Succeeded + } + super::ParentIntegrationState::Failed { .. } => CleanupTerminalOutcome::Failed, + super::ParentIntegrationState::Canceled { .. } => CleanupTerminalOutcome::Canceled, + _ => { + return Err(SchedulerError::Workspace { + detail: format!( + "captured parent {parent_id} has not reached a terminal controller state" + ), + }); + } + }; + let retained = outcome == CleanupTerminalOutcome::Failed + && self.workspace.retain_failed_workspaces(); + let resources = self + .hierarchy_state + .descendant_resources_for_generation(&parent_id, controller.hierarchy_generation); + let mut descendants = Vec::with_capacity(resources.len()); + for resource in resources { + let cleanup = self + .workspace + .cleanup_target_for_resource(&resource, CleanupTerminalOutcome::Succeeded) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + .ok_or_else(|| SchedulerError::Workspace { + detail: format!( + "captured parent {} is missing retained workspace generation {}", + parent_id, resource.checkout_generation + ), + })?; + descendants.push(ParentSubtreeCleanupTarget { + resource: Some(resource.clone()), + cleanup, + prepared_at: None, + cleaned_at: None, + }); + } + descendants.sort_by(|left, right| { + let left_resource = left.resource.as_ref().expect("descendant resource"); + let right_resource = right.resource.as_ref().expect("descendant resource"); + hierarchy_depth(&self.hierarchy_state, &parent_id, &right_resource.issue_id) + .cmp(&hierarchy_depth( + &self.hierarchy_state, + &parent_id, + &left_resource.issue_id, + )) + .then_with(|| left_resource.cmp(right_resource)) + }); + let intent = ParentSubtreeCleanupIntent { + hierarchy_generation: controller.hierarchy_generation, + capture_acknowledged_at: observed_at, + requested_at: observed_at, + outcome, + status: if retained { + ParentSubtreeCleanupStatus::Retained + } else { + ParentSubtreeCleanupStatus::Pending + }, + parent_root: ParentSubtreeCleanupTarget { + resource: None, + cleanup: CleanupTarget { + issue_id: parent_id.to_string(), + identifier: execution.issue().identifier.to_string(), + workspace: parent_workspace, + generation: format!("parent:{}", controller.hierarchy_generation), + outcome, + }, + prepared_at: None, + cleaned_at: None, + }, + descendants, + retry_count: 0, + last_error: None, + }; + intents.push((parent_id, intent)); + } + if !intents.is_empty() { + let previous_state = self.hierarchy_state.clone(); + for (parent_id, intent) in intents { + self.hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .expect("parent controller was cloned") + .subtree_cleanup = Some(intent); + } + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + } + let Some(tracker_snapshot) = self.load_tracker_snapshot(observed_at).await? else { + return Ok(()); + }; + let active_issue_ids = tracker_snapshot + .active + .iter() + .map(|issue| issue.id.clone()) + .collect::>(); + self.reconcile_parent_subtree_cleanup(observed_at, &active_issue_ids) + .await + } + + async fn reconcile_parent_subtree_cleanup( + &mut self, + observed_at: TimestampMs, + freshly_active_issue_ids: &HashSet, + ) -> Result<(), SchedulerError> { + let retain_failed = self.workspace.retain_failed_workspaces(); + let mut retention_changed = false; + for (parent_id, controller) in &mut self.hierarchy_state.parent_integrations { + let tracker_reopened = self.executions.get(parent_id).is_some_and(|execution| { + execution.issue().state.category == IssueStateCategory::Active + }); + if let Some(cleanup) = controller.subtree_cleanup.as_mut() { + let next_status = match cleanup.status { + ParentSubtreeCleanupStatus::Retained + if cleanup.outcome != CleanupTerminalOutcome::Failed || !retain_failed => + { + Some(ParentSubtreeCleanupStatus::Pending) + } + ParentSubtreeCleanupStatus::Pending | ParentSubtreeCleanupStatus::Removing + if cleanup.outcome == CleanupTerminalOutcome::Failed + && retain_failed + && !tracker_reopened + && cleanup.parent_root.cleaned_at.is_none() => + { + Some(ParentSubtreeCleanupStatus::Retained) + } + _ => None, + }; + if let Some(next_status) = next_status { + cleanup.status = next_status; + cleanup.last_error = None; + retention_changed = true; + } + } + } + if retention_changed { + self.persist_orchestrator_state().await?; + } + let parent_ids = self + .hierarchy_state + .parent_integrations + .iter() + .filter(|(_, controller)| { + controller.subtree_cleanup.as_ref().is_some_and(|cleanup| { + matches!( + cleanup.status, + ParentSubtreeCleanupStatus::Pending | ParentSubtreeCleanupStatus::Removing + ) + }) + }) + .map(|(parent_id, _)| parent_id.clone()) + .collect::>(); + for parent_id in parent_ids { + let parent_root = self + .hierarchy_state + .parent_integrations + .get(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_ref()) + .map(|cleanup| cleanup.parent_root.clone()) + .expect("cleanup intent exists"); + if parent_root.prepared_at.is_none() { + match self + .workspace + .prepare_cleanup_generation(&parent_root.cleanup) + .await + { + Ok(()) => { + self.hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("cleanup intent exists") + .parent_root + .prepared_at = Some(observed_at); + self.persist_orchestrator_state().await?; + } + Err(error) => { + let cleanup = self + .hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("cleanup intent exists"); + cleanup.status = ParentSubtreeCleanupStatus::Pending; + cleanup.retry_count = cleanup.retry_count.saturating_add(1); + cleanup.last_error = Some(redact_runtime_diagnostic(&error.to_string())); + self.persist_orchestrator_state().await?; + continue; + } + } + } + let descendants = self + .hierarchy_state + .parent_integrations + .get(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_ref()) + .map(|cleanup| cleanup.descendants.clone()) + .unwrap_or_default(); + let previous_state = self.hierarchy_state.clone(); + let mut leases_released = false; + for target in &descendants { + let resource = target.resource.as_ref().expect("descendant resource"); + if self.hierarchy_state.release_parent_resource_leases( + &parent_id, + resource, + observed_at.as_u64(), + ) { + leases_released = true; + } + } + if leases_released && let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + return Err(error); + } + if let Some(cleanup) = self + .hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + { + cleanup.status = ParentSubtreeCleanupStatus::Removing; + cleanup.last_error = None; + } + self.persist_orchestrator_state().await?; + + let mut failed = false; + for (index, target) in descendants.iter().enumerate() { + if target.cleaned_at.is_some() { + continue; + } + let resource = target.resource.as_ref().expect("descendant resource"); + let fresh_tracker_fence = freshly_active_issue_ids + .contains(resource.issue_id.as_str()) + && !self + .executions + .get(&resource.issue_id) + .and_then(IssueExecution::workspace) + .is_some_and(|workspace| workspace.path != target.cleanup.workspace.path); + if self + .hierarchy_state + .active_for_at(resource, observed_at.as_u64()) + || fresh_tracker_fence + || self + .executions + .get(&resource.issue_id) + .is_some_and(|execution| { + matches!( + execution.status(), + SchedulerStatus::Claimed + | SchedulerStatus::Running + | SchedulerStatus::RetryQueued + ) && execution.workspace().is_some_and(|workspace| { + workspace.path == target.cleanup.workspace.path + }) + }) + { + continue; + } + match self.workspace.cleanup_generation(&target.cleanup).await { + Ok(()) => { + if let Some(execution) = self.executions.get_mut(&resource.issue_id) + && execution.workspace().is_some_and(|workspace| { + workspace.path == target.cleanup.workspace.path + }) + { + execution.clear_workspace(); + } + self.hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("cleanup intent exists") + .descendants[index] + .cleaned_at = Some(observed_at); + self.persist_orchestrator_state().await?; + } + Err(error) => { + let cleanup = self + .hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("cleanup intent exists"); + cleanup.status = ParentSubtreeCleanupStatus::Pending; + cleanup.retry_count = cleanup.retry_count.saturating_add(1); + cleanup.last_error = Some(redact_runtime_diagnostic(&error.to_string())); + self.persist_orchestrator_state().await?; + failed = true; + break; + } + } + } + if failed { + continue; + } + let cleanup = self + .hierarchy_state + .parent_integrations + .get(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_ref()) + .expect("cleanup intent exists") + .clone(); + if cleanup + .descendants + .iter() + .any(|target| target.cleaned_at.is_none()) + { + if let Some(cleanup) = self + .hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + { + cleanup.status = ParentSubtreeCleanupStatus::Pending; + } + self.persist_orchestrator_state().await?; + continue; + } + if cleanup.parent_root.cleaned_at.is_none() { + let previous_state = self.hierarchy_state.clone(); + let previous_execution = self.executions.get(&parent_id).cloned(); + match self + .workspace + .cleanup_generation(&cleanup.parent_root.cleanup) + .await + { + Ok(()) => { + if let Some(execution) = self.executions.get_mut(&parent_id) + && execution.workspace().is_some_and(|workspace| { + workspace.path == cleanup.parent_root.cleanup.workspace.path + }) + { + execution.clear_workspace(); + } + let cleanup = self + .hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("cleanup intent exists"); + cleanup.parent_root.cleaned_at = Some(observed_at); + cleanup.status = ParentSubtreeCleanupStatus::Completed; + cleanup.last_error = None; + if let Err(error) = self.persist_orchestrator_state().await { + self.hierarchy_state = previous_state; + if let Some(execution) = previous_execution { + self.executions.insert(parent_id.clone(), execution); + } + return Err(error); + } + } + Err(error) => { + let cleanup = self + .hierarchy_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("cleanup intent exists"); + cleanup.status = ParentSubtreeCleanupStatus::Pending; + cleanup.retry_count = cleanup.retry_count.saturating_add(1); + cleanup.last_error = Some(redact_runtime_diagnostic(&error.to_string())); + self.persist_orchestrator_state().await?; + } + } + } + } + Ok(()) + } + pub async fn interrupt_operator_cancel( &mut self, target: &str, @@ -2358,6 +2857,19 @@ where if snapshot.blocked_reason != Some(HierarchyBlockedReason::HierarchyChanged) { return Ok(false); } + if self + .hierarchy_state + .parent_integrations + .get(parent_id) + .and_then(|controller| controller.subtree_cleanup.as_ref()) + .is_some_and(|cleanup| cleanup.status != ParentSubtreeCleanupStatus::Completed) + { + return Err(SchedulerError::Workspace { + detail: format!( + "cannot replan hierarchy parent {parent_id} while subtree cleanup is incomplete" + ), + }); + } if self.executions.get(parent_id).is_some_and(|execution| { matches!( execution.status(), @@ -2987,6 +3499,28 @@ where || snapshot.frozen || snapshot.blocked_reason.is_some() }); + let cleanup_blocks_reconciliation = self + .hierarchy_state + .parent_integrations + .get(&normalized.id) + .and_then(|controller| controller.subtree_cleanup.as_ref()) + .is_some_and(|cleanup| cleanup.status != ParentSubtreeCleanupStatus::Completed); + if cleanup_blocks_reconciliation { + let mut changed = false; + if normalized.state.category == IssueStateCategory::Active + && let Some(cleanup) = self + .hierarchy_state + .parent_integrations + .get_mut(&normalized.id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + && cleanup.status == ParentSubtreeCleanupStatus::Retained + { + cleanup.status = ParentSubtreeCleanupStatus::Pending; + cleanup.last_error = None; + changed = true; + } + return Ok(changed); + } if !should_retain_parent_identity { self.parent_issue_ids.remove(&normalized.id); self.terminal_undispatched_parent_ids.remove(&normalized.id); @@ -3311,6 +3845,34 @@ where .map(|pending| (pending.issue.id.clone(), pending)) .collect::>(); let mut records = records; + records.retain(|record| { + let Some(generation) = recovered_parent_hierarchy_generation(&record.workspace.path) + else { + return true; + }; + let expected_generation = self + .hierarchy_state + .parent_integrations + .get(&record.issue.id) + .map(|controller| controller.hierarchy_generation) + .or_else(|| { + self.hierarchy_state + .hierarchy + .get(&record.issue.id) + .map(|snapshot| snapshot.generation) + }); + let current = expected_generation.is_none_or(|expected| generation == expected); + if !current { + tracing::warn!( + issue = %record.issue.id, + recovered_generation = generation, + controller_generation = expected_generation, + path = %record.workspace.path.display(), + "skipping superseded parent execution root during scheduler recovery" + ); + } + current + }); if self .migrate_legacy_in_flight_parent_controllers(&records, observed_at) .await? @@ -3908,6 +4470,21 @@ where self.terminal_child_failure_ids.insert(issue_id.clone()); continue; } + let owned_by_subtree_cleanup = match self + .workspace_is_owned_by_subtree_cleanup(&record.issue, &record.workspace) + .await + { + Ok(owned) => owned, + Err(error) => { + retry_records.push(record); + retry_records.extend(records.iter().skip(record_index + 1).cloned()); + self.pending_recovery = Some(retry_records); + return Err(error); + } + }; + if owned_by_subtree_cleanup { + continue; + } if let Err(error) = self .retain_terminal_child_lease_for_workspace(&record.issue, &record.workspace) .await @@ -3970,6 +4547,19 @@ where .map_err(|error| SchedulerError::Workspace { detail: error.to_string(), })?; + if parent_workspace { + let mut execution = + IssueExecution::new(record.issue.clone(), observed_at); + execution.attach_workspace(record.workspace.clone())?; + self.insert_execution( + issue_id.clone(), + execution.release( + observed_at, + ReleaseReason::TrackerTerminal, + None, + )?, + ); + } } Err(error) => { tracing::warn!(issue = %issue_id, %error, "deferring terminal workspace cleanup retry"); @@ -8059,6 +8649,33 @@ where Ok(true) } + async fn workspace_is_owned_by_subtree_cleanup( + &mut self, + issue: &NormalizedIssue, + workspace: &WorkspaceRecord, + ) -> Result { + let Some(resource) = self + .workspace + .workspace_lease_resource(issue, workspace) + .await + .map_err(|error| SchedulerError::Workspace { + detail: error.to_string(), + })? + else { + return Ok(false); + }; + Ok(self + .hierarchy_state + .parent_integrations + .values() + .filter_map(|controller| controller.subtree_cleanup.as_ref()) + .filter(|cleanup| cleanup.status != ParentSubtreeCleanupStatus::Completed) + .flat_map(|cleanup| cleanup.descendants.iter()) + .any(|target| { + target.cleaned_at.is_none() && target.resource.as_ref() == Some(&resource) + })) + } + async fn workspace_has_active_lease( &mut self, workspace: &WorkspaceRecord, @@ -8890,6 +9507,38 @@ fn current_epoch_millis() -> u64 { .as_millis() as u64 } +fn hierarchy_depth( + state: &DurableOrchestratorState, + parent_id: &IssueId, + target_id: &IssueId, +) -> usize { + let mut pending = vec![(parent_id.clone(), 0usize)]; + let mut visited = BTreeSet::new(); + while let Some((issue_id, depth)) = pending.pop() { + if !visited.insert(issue_id.clone()) { + continue; + } + if &issue_id == target_id { + return depth; + } + if let Some(snapshot) = state.hierarchy.get(&issue_id) { + pending.extend( + snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .map(|edge| (edge.child_id.clone(), depth.saturating_add(1))), + ); + } + } + 0 +} + +fn recovered_parent_hierarchy_generation(path: &Path) -> Option { + let generation = path.file_name()?.to_str()?.parse::().ok()?; + (path.parent()?.parent()?.file_name()?.to_str()? == "parents").then_some(generation) +} + fn conversation_id_suffix(value: &str) -> &str { value.get(value.len().saturating_sub(8)..).unwrap_or(value) } diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index cae7a6a25..214d72df2 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -594,6 +594,10 @@ struct FakeWorkspace { repair_refresh_commit: Option, repair_refresh_instruction_hash: Option, repair_refresh_instruction_path: Option, + cleanup_target_requests: Vec, + generation_cleanups: Vec, + generation_cleanup_steps: Vec, + lease_resources: HashMap, } impl WorkspaceBackend for FakeWorkspace { @@ -625,6 +629,14 @@ impl WorkspaceBackend for FakeWorkspace { Ok(self.recoveries.clone()) } + async fn workspace_lease_resource( + &mut self, + issue: &NormalizedIssue, + _workspace: &WorkspaceRecord, + ) -> Result, Self::Error> { + Ok(self.lease_resources.get(issue.id.as_str()).cloned()) + } + async fn parent_workspace_targets( &mut self, _issue: &NormalizedIssue, @@ -763,6 +775,43 @@ impl WorkspaceBackend for FakeWorkspace { self.cleanup_results.pop_front().unwrap_or(Ok(())) } + async fn cleanup_target_for_resource( + &mut self, + resource: &LeaseResource, + outcome: crate::opensymphony_workspace::CleanupTerminalOutcome, + ) -> Result, Self::Error> { + self.cleanup_target_requests.push(resource.clone()); + Ok(Some(crate::opensymphony_workspace::CleanupTarget { + issue_id: resource.issue_id.to_string(), + identifier: resource.issue_id.to_string(), + workspace: workspace_record( + resource.issue_id.as_str(), + &format!("/tmp/workspaces/{}", resource.issue_id), + ), + generation: resource.checkout_generation.clone(), + outcome, + })) + } + + async fn cleanup_generation( + &mut self, + target: &crate::opensymphony_workspace::CleanupTarget, + ) -> Result<(), Self::Error> { + self.generation_cleanup_steps + .push(format!("cleanup:{}", target.issue_id)); + self.generation_cleanups.push(target.clone()); + self.cleanup_workspace(&target.workspace, true).await + } + + async fn prepare_cleanup_generation( + &mut self, + target: &crate::opensymphony_workspace::CleanupTarget, + ) -> Result<(), Self::Error> { + self.generation_cleanup_steps + .push(format!("prepare:{}", target.issue_id)); + self.cleanup_results.pop_front().unwrap_or(Ok(())) + } + async fn cleanup_failed_workspace( &mut self, workspace: &WorkspaceRecord, @@ -1710,6 +1759,166 @@ async fn terminal_parent_between_repair_turns_cancels_and_persists_its_controlle review_requests_before_terminal, "fresh terminal state must fence review writes before repair advancement" ); + + scheduler.workspace_mut().retain_failed = true; + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(7_300_400)) + .await + .expect("canceled parent capture should use the cancellation policy"); + let retained: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode retained cleanup policy"); + assert_eq!( + retained.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("capture acknowledgement") + .status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Completed + ); + assert_eq!(scheduler.workspace().generation_cleanup_steps.len(), 3); + + let mut legacy_retained = retained; + let controller = legacy_retained + .parent_integrations + .get_mut(&parent_id) + .expect("parent controller"); + controller.state = crate::opensymphony_orchestrator::ParentIntegrationState::Failed { + reason: "diagnostic failure".to_owned(), + }; + let cleanup = controller.subtree_cleanup.as_mut().expect("cleanup intent"); + cleanup.outcome = crate::opensymphony_workspace::CleanupTerminalOutcome::Failed; + cleanup.status = crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Retained; + cleanup.parent_root.cleanup.outcome = + crate::opensymphony_workspace::CleanupTerminalOutcome::Failed; + cleanup.parent_root.prepared_at = None; + cleanup.parent_root.cleaned_at = None; + for descendant in &mut cleanup.descendants { + descendant.cleaned_at = None; + } + let mut legacy_pending = legacy_retained.clone(); + legacy_pending + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("cleanup intent") + .status = crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Pending; + let mut retained_retry = Scheduler::new( + FakeTracker::default(), + FakeWorkspace { + durable_state: Some(serde_json::to_value(&legacy_pending).expect("pending state")), + retain_failed: true, + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + retained_retry + .tick(ts(7_300_450)) + .await + .expect("enabling failed retention must stop an incomplete cleanup"); + let retained_retry_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + retained_retry + .workspace() + .durable_state + .clone() + .expect("state"), + ) + .expect("retained retry state"); + assert_eq!( + retained_retry_state.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("cleanup") + .status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Retained + ); + assert!( + retained_retry + .workspace() + .generation_cleanup_steps + .is_empty() + ); + let full_refreshes = retained_retry.tracker().active_requests; + retained_retry + .tick(ts(7_300_451)) + .await + .expect("retained failed cleanup should remain idle"); + assert_eq!( + retained_retry.tracker().active_requests, + full_refreshes, + "retained failed cleanup must not force a full tracker refresh every poll" + ); + + let mut reopened_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "In Progress", + 7_300_460, + ); + reopened_parent.sub_issues = vec![TrackerIssueRef { + id: format!("child-{suffix}"), + identifier: format!("COE-CHILD-{suffix}"), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let mut reopened_retry = Scheduler::new( + FakeTracker { + active: vec![reopened_parent], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&legacy_pending).expect("pending state")), + retain_failed: true, + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + reopened_retry + .tick(ts(7_300_460)) + .await + .expect("live reopen should release the diagnostic retention hold"); + reopened_retry + .tick(ts(10_900_460)) + .await + .expect("reopened failed parent should finish prior-generation cleanup"); + assert!( + !reopened_retry + .workspace() + .generation_cleanup_steps + .is_empty(), + "active reopen must not oscillate between pending and retained" + ); + + let mut resumed = Scheduler::new( + FakeTracker::default(), + FakeWorkspace { + durable_state: Some(serde_json::to_value(&legacy_retained).expect("retained state")), + retain_failed: false, + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + resumed + .tick(ts(7_300_500)) + .await + .expect("disabling failed retention must resume durable cleanup"); + let resumed_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(resumed.workspace().durable_state.clone().expect("state")) + .expect("resumed state"); + assert_eq!( + resumed_state.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("cleanup") + .status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Completed + ); } #[tokio::test] @@ -1848,6 +2057,19 @@ async fn terminal_parent_fences_a_pending_repair_push() { pushes_before_terminal, "fresh terminal state must fence a repair push before repair advancement" ); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let controller = &state.parent_integrations[&parent_id]; + assert!(controller.has_unreconciled_provider_operation()); + assert!( + !controller.state.terminal(), + "a parent cannot become cleanup eligible while a provider write is unresolved" + ); + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(7_300_400)) + .await + .expect_err("unresolved provider intent must fence capture cleanup"); } #[tokio::test] @@ -2841,158 +3063,27 @@ async fn completed_parent_stays_materialized_across_later_capture_retries() { restarted_state.leases.iter().any(LeaseRecord::active), "restart must retain the leases that protect parent evidence" ); -} - -#[tokio::test] -async fn passed_parent_survives_tracker_refresh_failure_until_terminal_reconciliation() { - let suffix = "PASSED-REFRESH"; - let mut config = scheduler_config(); - config.max_retry_attempts = Some(0); - let (mut scheduler, parent_id) = - launched_parent_scheduler_with_config(suffix, config.clone()).await; - scheduler.tracker_mut().state_errors.push_back(FakeError { - message: "temporary tracker refresh failure".to_owned(), - category: None, - retry_after: None, - }); - enqueue_successful_parent_completion(&mut scheduler, suffix, 150); - scheduler - .tick(ts(150)) - .await - .expect("verified outcome should remain durable despite tracker refresh failure"); - let state: crate::opensymphony_orchestrator::DurableOrchestratorState = - serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) - .expect("durable state"); - assert_eq!( - state.parent_integrations[&parent_id].attempts[0].status, - crate::opensymphony_orchestrator::ParentAttemptStatus::Passed - ); - assert!( - state.parent_integrations[&parent_id] - .current_attempt_id() - .is_none(), - "a passed attempt must not be converted into a retrying attempt" - ); - assert!(matches!( - scheduler - .execution(&parent_id) - .expect("passed parent execution remains observable") - .state(), - crate::opensymphony_orchestrator::SchedulerState::Released { - reason: ReleaseReason::Completed, - .. - } - )); - let launch_count = scheduler.worker().launches.len(); - scheduler - .tick(ts(1_200)) + restarted + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(3_600_310)) .await - .expect("active refresh should keep the passed parent parked"); - assert_eq!( - scheduler.worker().launches.len(), - launch_count, - "tracker confirmation must not start another parent attempt" - ); - - let recovered_issue = scheduler - .execution(&parent_id) - .expect("passed parent execution") - .issue() - .clone(); - let recovered_workspace = scheduler - .execution(&parent_id) - .and_then(|execution| execution.workspace()) - .cloned() - .expect("passed parent workspace"); - let mut durable_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + .expect("a recovered completed parent must remain capture eligible"); + let captured_state: crate::opensymphony_orchestrator::DurableOrchestratorState = serde_json::from_value( - scheduler + restarted .workspace() .durable_state .clone() - .expect("passed controller state"), + .expect("captured state"), ) - .expect("decode passed controller state"); - durable_state.leases.clear(); - let active_parent = scheduler.tracker().active[0].clone(); - let mut scheduler = Scheduler::new( - FakeTracker { - active: vec![active_parent], - ..Default::default() - }, - FakeWorkspace { - recoveries: vec![RecoveryRecord { - issue: recovered_issue, - workspace: recovered_workspace, - successful_run: true, - cancelled_run: false, - completed_run: true, - had_in_flight_run: false, - pending_retry: false, - normal_retry_count: 0, - retry_scheduled_at: None, - retry_due_at: None, - retry_reason: None, - retry_error: None, - harness_kind: None, - interrupt_reason: None, - recovered_run: None, - }], - durable_state: Some(serde_json::to_value(durable_state).expect("passed state")), - ..Default::default() - }, - FakeWorker::default(), - config, - ); - scheduler - .tick(ts(1_300)) - .await - .expect("restart should preserve tracker-confirmation wait"); - assert!(scheduler.worker().launches.is_empty()); - assert!(matches!( - scheduler - .execution(&parent_id) - .expect("recovered passed parent") - .state(), - crate::opensymphony_orchestrator::SchedulerState::Released { - reason: ReleaseReason::Completed, - .. - } - )); - - scheduler.tracker_mut().active.clear(); - let mut terminal_parent = tracker_issue( - parent_id.as_str(), - &format!("COE-PARENT-{suffix}"), - "Done", - 0, - ); - terminal_parent.sub_issues = vec![TrackerIssueRef { - id: format!("child-{suffix}"), - identifier: format!("COE-CHILD-{suffix}"), - title: Some("Child".to_owned()), - url: None, - state: "Done".to_owned(), - state_kind: TrackerIssueStateKind::Completed, - }]; - scheduler.tracker_mut().terminal = vec![terminal_parent]; - scheduler - .tick(ts(3_600_200)) - .await - .expect("later terminal refresh should complete the preserved attempt"); - let state: crate::opensymphony_orchestrator::DurableOrchestratorState = - serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) - .expect("durable state"); - assert_eq!( - state.parent_integrations[&parent_id].state, - crate::opensymphony_orchestrator::ParentIntegrationState::Completed - ); + .expect("decode captured state"); + assert!(captured_state.leases.iter().all(|lease| !lease.active())); + assert_eq!(restarted.workspace().generation_cleanups.len(), 2); } #[tokio::test] -async fn stale_completed_parent_does_not_release_changed_hierarchy() { - let suffix = "STALE-COMPLETED"; +async fn captured_parent_releases_owned_leases_and_cleans_descendants_before_root_once() { + let suffix = "CAPTURE-CLEANUP"; let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; let mut terminal = tracker_state_snapshot( parent_id.as_str(), @@ -3008,17 +3099,764 @@ async fn stale_completed_parent_does_not_release_changed_hierarchy() { .states .insert(parent_id.to_string(), terminal); enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler.tick(ts(150)).await.expect("complete parent"); + scheduler - .tick(ts(150)) + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(160)) .await - .expect("terminal parent outcome should complete"); + .expect("capture acknowledgement should drive cleanup"); - let mut changed_parent = tracker_issue( - parent_id.as_str(), - &format!("COE-PARENT-{suffix}"), - "Done", - 0, - ); + let cleaned = &scheduler.workspace().generation_cleanups; + assert_eq!(cleaned.len(), 2); + assert_eq!(cleaned[0].issue_id, format!("child-{suffix}")); + assert_eq!(cleaned[1].issue_id, parent_id.to_string()); + assert_eq!( + scheduler.workspace().cleanup_target_requests, + vec![LeaseResource { + issue_id: IssueId::new(format!("child-{suffix}")).expect("child id"), + repository_id: CanonicalRepositoryId::new(format!("github:repository:{suffix}")) + .expect("repository id"), + checkout_generation: format!("checkout-{suffix}"), + }], + "cleanup must resolve the exact retained generation through the workspace backend" + ); + assert_eq!( + scheduler.workspace().generation_cleanup_steps, + vec![ + format!("prepare:{parent_id}"), + format!("cleanup:child-{suffix}"), + format!("cleanup:{parent_id}"), + ], + "parent integration worktrees must be detached before source generations" + ); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + assert!(state.leases.iter().all(|lease| !lease.active())); + let cleanup = state.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("cleanup intent"); + assert_eq!( + cleanup.status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Completed + ); + assert_eq!(cleanup.capture_acknowledged_at, ts(160)); + assert_eq!( + scheduler.completed_subtree_cleanup_identifiers(), + [format!("COE-PARENT-{suffix}")].into_iter().collect(), + "completed cleanup remains the durable restart marker for automatic capture" + ); + + scheduler.tick(ts(170)).await.expect("repeat cleanup tick"); + assert_eq!( + scheduler.workspace().generation_cleanups.len(), + 2, + "completed cleanup receipts must make retries no-ops" + ); +} + +#[tokio::test] +async fn capture_acknowledgement_rolls_back_cleanup_intent_when_persistence_fails() { + let suffix = "CAPTURE-ACK-ROLLBACK"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler.tick(ts(150)).await.expect("complete parent"); + scheduler + .workspace_mut() + .persist_durable_state_results + .push_back(Err(FakeError { + message: "capture acknowledgement persistence failed".to_owned(), + category: None, + retry_after: None, + })); + + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(160)) + .await + .expect_err("capture acknowledgement must fail closed"); + + assert!(scheduler.workspace().generation_cleanup_steps.is_empty()); + assert_eq!(scheduler.workspace().cleanup_target_requests.len(), 1); + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(170)) + .await + .expect("capture acknowledgement should be retryable"); + assert_eq!( + scheduler.workspace().cleanup_target_requests.len(), + 2, + "retry must reconstruct the cleanup intent after rollback" + ); + assert_eq!(scheduler.workspace().generation_cleanups.len(), 2); +} + +#[tokio::test] +async fn subtree_cleanup_rolls_back_unpersisted_lease_release() { + let suffix = "CAPTURE-LEASE-ROLLBACK"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler.tick(ts(150)).await.expect("complete parent"); + scheduler + .workspace_mut() + .persist_durable_state_results + .extend([ + Ok(()), + Ok(()), + Err(FakeError { + message: "lease release persistence failed".to_owned(), + category: None, + retry_after: None, + }), + ]); + + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(160)) + .await + .expect_err("unpersisted lease release must stop cleanup"); + assert!(scheduler.workspace().generation_cleanups.is_empty()); + + scheduler + .workspace_mut() + .persist_durable_state_results + .push_back(Err(FakeError { + message: "lease release retry persistence failed".to_owned(), + category: None, + retry_after: None, + })); + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(170)) + .await + .expect_err("rolled-back lease release must retry durable persistence"); + assert!(scheduler.workspace().generation_cleanups.is_empty()); + + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(180)) + .await + .expect("persisted lease release should allow cleanup"); + assert_eq!(scheduler.workspace().generation_cleanups.len(), 2); +} + +#[tokio::test] +async fn subtree_cleanup_rolls_back_unpersisted_completion_receipt() { + let suffix = "CAPTURE-COMPLETION-ROLLBACK"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler.tick(ts(150)).await.expect("complete parent"); + scheduler + .workspace_mut() + .persist_durable_state_results + .extend([ + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Ok(()), + Err(FakeError { + message: "completion receipt persistence failed".to_owned(), + category: None, + retry_after: None, + }), + ]); + + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(160)) + .await + .expect_err("unpersisted completion must remain retryable"); + assert!(scheduler.completed_subtree_cleanup_identifiers().is_empty()); + assert_eq!(scheduler.workspace().generation_cleanups.len(), 2); + assert!( + scheduler + .execution(&parent_id) + .expect("parent execution") + .workspace() + .is_some(), + "the in-memory parent binding must roll back with its completion receipt" + ); + + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(170)) + .await + .expect("the matching deletion tombstone should complete the retry"); + assert_eq!( + scheduler.completed_subtree_cleanup_identifiers(), + [format!("COE-PARENT-{suffix}")].into_iter().collect() + ); + assert_eq!(scheduler.workspace().generation_cleanups.len(), 3); +} + +#[tokio::test] +async fn failed_subtree_cleanup_retries_only_incomplete_receipts() { + let suffix = "CAPTURE-CLEANUP-RETRY"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler.tick(ts(150)).await.expect("complete parent"); + scheduler.workspace_mut().cleanup_results = VecDeque::from([ + Ok(()), + Err(FakeError { + message: "permission denied".to_owned(), + category: None, + retry_after: None, + }), + Ok(()), + Ok(()), + ]); + + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(160)) + .await + .expect("cleanup failures are durable retry state"); + let failed_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let failed = failed_state.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("cleanup intent"); + assert_eq!(failed.retry_count, 1); + assert_eq!(failed.last_error.as_deref(), Some("permission denied")); + assert!(failed.parent_root.prepared_at.is_some()); + assert!(failed.descendants[0].cleaned_at.is_none()); + + let child_id = IssueId::new(format!("child-{suffix}")).expect("child id"); + let resource = failed.descendants[0] + .resource + .clone() + .expect("descendant resource"); + let mut child = normalized_issue(child_id.as_str(), &format!("COE-CHILD-{suffix}"), "Done"); + child.parent_id = Some(parent_id.clone()); + child.sub_issues.clear(); + let child_workspace = workspace_record( + &format!("COE-CHILD-{suffix}"), + &format!("/tmp/workspaces/COE-CHILD-{suffix}"), + ); + let mut terminal_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + 0, + ); + terminal_parent.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: format!("COE-CHILD-{suffix}"), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + let terminal_child = + tracker_issue(child_id.as_str(), &format!("COE-CHILD-{suffix}"), "Done", 0); + let mut restarted = Scheduler::new( + FakeTracker { + terminal: vec![terminal_parent, terminal_child], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&failed_state).expect("pending cleanup")), + recoveries: vec![RecoveryRecord { + issue: child, + workspace: child_workspace, + successful_run: true, + cancelled_run: false, + completed_run: true, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: None, + }], + lease_resources: HashMap::from([(child_id.to_string(), resource)]), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + restarted + .bootstrap(ts(165)) + .await + .expect("restart should preserve cleanup ownership"); + let bootstrap_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + restarted + .workspace() + .durable_state + .clone() + .expect("bootstrap state"), + ) + .expect("bootstrap state"); + assert!(bootstrap_state.leases.iter().all(|lease| !lease.active())); + assert!( + restarted.workspace().cleaned.is_empty(), + "generic recovery must not delete a generation owned by subtree cleanup" + ); + restarted.tick(ts(170)).await.expect("retry cleanup"); + let restarted_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(restarted.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + assert_eq!( + restarted_state.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("cleanup intent") + .status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Completed + ); + + let mut active_state = failed_state.clone(); + active_state + .parent_integrations + .get_mut(&parent_id) + .and_then(|controller| controller.subtree_cleanup.as_mut()) + .expect("pending cleanup") + .descendants[0] + .cleanup + .workspace = workspace_record( + &format!("COE-CHILD-{suffix}"), + &format!("/tmp/workspaces/COE-CHILD-{suffix}"), + ); + let active_workspace = active_state.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("pending cleanup") + .descendants[0] + .cleanup + .workspace + .clone(); + let mut active_retry = Scheduler::new( + FakeTracker { + active: vec![tracker_issue( + child_id.as_str(), + &format!("COE-CHILD-{suffix}"), + "In Progress", + 165, + )], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(serde_json::to_value(&active_state).expect("pending state")), + records: HashMap::from([(child_id.to_string(), active_workspace)]), + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + active_retry + .tick(ts(165)) + .await + .expect("fresh tracker state must fence cleanup before child reactivation"); + assert_eq!( + active_retry + .execution(&child_id) + .expect("active child") + .status(), + SchedulerStatus::Running + ); + let cleanup_steps_before_active_fence = + active_retry.workspace().generation_cleanup_steps.clone(); + assert!( + cleanup_steps_before_active_fence.is_empty(), + "cleanup must not run before the tick observes and dispatches the reopened child" + ); + active_retry + .tick(ts(170)) + .await + .expect("active child must fence the pending cleanup retry"); + assert_eq!( + active_retry.workspace().generation_cleanup_steps, + cleanup_steps_before_active_fence, + "cleanup must not delete a workspace under a claimed or running child" + ); + + let retry_run = active_retry.worker().launches[0].run.clone(); + active_retry + .worker_mut() + .updates + .push_back(WorkerUpdate::Finished { + worker_id: retry_run.worker_id.clone(), + outcome: WorkerOutcomeRecord::from_run( + &retry_run, + WorkerOutcomeKind::Failed, + ts(175), + None, + Some("retryable descendant failure".to_owned()), + ), + }); + active_retry + .tick(ts(175)) + .await + .expect("the failed descendant should queue a continuation"); + assert_eq!( + active_retry + .execution(&child_id) + .expect("retrying child") + .status(), + SchedulerStatus::RetryQueued + ); + active_retry + .tick(ts(180)) + .await + .expect("retry-queued child must fence old-generation cleanup"); + assert_eq!( + active_retry.workspace().generation_cleanup_steps, + cleanup_steps_before_active_fence, + "cleanup must retain a workspace queued for continuation" + ); + + scheduler.tick(ts(170)).await.expect("retry cleanup"); + let recovered_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("decode state"); + let recovered = recovered_state.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("cleanup intent"); + assert_eq!( + recovered.status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Completed + ); + assert_eq!( + scheduler.workspace().generation_cleanup_steps, + vec![ + format!("prepare:{parent_id}"), + format!("cleanup:child-{suffix}"), + format!("cleanup:child-{suffix}"), + format!("cleanup:{parent_id}"), + ], + "the durable parent preparation receipt must not be repeated" + ); +} + +#[tokio::test] +async fn reopened_parent_waits_for_pending_subtree_cleanup_before_replanning() { + let suffix = "CAPTURE-REOPEN-CLEANUP"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let child_id = IssueId::new(format!("child-{suffix}")).expect("child id"); + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler.tick(ts(150)).await.expect("complete parent"); + scheduler.workspace_mut().cleanup_results = VecDeque::from([ + Ok(()), + Err(FakeError { + message: "diagnostic hold".to_owned(), + category: None, + retry_after: None, + }), + ]); + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(160)) + .await + .expect("cleanup failure is retryable"); + + let mut reopened = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "In Progress", + 3_600_170, + ); + reopened.sub_issues = vec![TrackerIssueRef { + id: child_id.to_string(), + identifier: format!("COE-CHILD-{suffix}"), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + scheduler.tracker_mut().active = vec![reopened]; + scheduler.tracker_mut().states.clear(); + scheduler + .workspace_mut() + .cleanup_results + .push_back(Err(FakeError { + message: "diagnostic hold remains".to_owned(), + category: None, + retry_after: None, + })); + + scheduler + .tick(ts(3_600_170)) + .await + .expect("reopen must leave the old controller intact while cleanup is blocked"); + let blocked: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("blocked state"); + let blocked_controller = &blocked.parent_integrations[&parent_id]; + assert!(matches!( + blocked_controller.state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + )); + assert_eq!( + blocked_controller + .subtree_cleanup + .as_ref() + .expect("old cleanup intent") + .status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Pending + ); + assert_eq!(scheduler.worker().launches.len(), 1); + + scheduler + .tick(ts(7_200_170)) + .await + .expect("reopen may replan after the prior subtree is removed"); + let replanned: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("replanned state"); + assert!( + replanned.parent_integrations[&parent_id] + .subtree_cleanup + .is_none(), + "the new controller is installed only after the prior cleanup completes" + ); + assert_ne!( + replanned.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + ); +} + +#[tokio::test] +async fn passed_parent_survives_tracker_refresh_failure_until_terminal_reconciliation() { + let suffix = "PASSED-REFRESH"; + let mut config = scheduler_config(); + config.max_retry_attempts = Some(0); + let (mut scheduler, parent_id) = + launched_parent_scheduler_with_config(suffix, config.clone()).await; + scheduler.tracker_mut().state_errors.push_back(FakeError { + message: "temporary tracker refresh failure".to_owned(), + category: None, + retry_after: None, + }); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + + scheduler + .tick(ts(150)) + .await + .expect("verified outcome should remain durable despite tracker refresh failure"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id].attempts[0].status, + crate::opensymphony_orchestrator::ParentAttemptStatus::Passed + ); + assert!( + state.parent_integrations[&parent_id] + .current_attempt_id() + .is_none(), + "a passed attempt must not be converted into a retrying attempt" + ); + assert!(matches!( + scheduler + .execution(&parent_id) + .expect("passed parent execution remains observable") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::Completed, + .. + } + )); + let launch_count = scheduler.worker().launches.len(); + scheduler + .tick(ts(1_200)) + .await + .expect("active refresh should keep the passed parent parked"); + assert_eq!( + scheduler.worker().launches.len(), + launch_count, + "tracker confirmation must not start another parent attempt" + ); + + let recovered_issue = scheduler + .execution(&parent_id) + .expect("passed parent execution") + .issue() + .clone(); + let recovered_workspace = scheduler + .execution(&parent_id) + .and_then(|execution| execution.workspace()) + .cloned() + .expect("passed parent workspace"); + let mut durable_state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value( + scheduler + .workspace() + .durable_state + .clone() + .expect("passed controller state"), + ) + .expect("decode passed controller state"); + durable_state.leases.clear(); + let active_parent = scheduler.tracker().active[0].clone(); + let mut scheduler = Scheduler::new( + FakeTracker { + active: vec![active_parent], + ..Default::default() + }, + FakeWorkspace { + recoveries: vec![RecoveryRecord { + issue: recovered_issue, + workspace: recovered_workspace, + successful_run: true, + cancelled_run: false, + completed_run: true, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: None, + }], + durable_state: Some(serde_json::to_value(durable_state).expect("passed state")), + ..Default::default() + }, + FakeWorker::default(), + config, + ); + scheduler + .tick(ts(1_300)) + .await + .expect("restart should preserve tracker-confirmation wait"); + assert!(scheduler.worker().launches.is_empty()); + assert!(matches!( + scheduler + .execution(&parent_id) + .expect("recovered passed parent") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::Completed, + .. + } + )); + + scheduler.tracker_mut().active.clear(); + let mut terminal_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + 0, + ); + terminal_parent.sub_issues = vec![TrackerIssueRef { + id: format!("child-{suffix}"), + identifier: format!("COE-CHILD-{suffix}"), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }]; + scheduler.tracker_mut().terminal = vec![terminal_parent]; + scheduler + .tick(ts(3_600_200)) + .await + .expect("later terminal refresh should complete the preserved attempt"); + let state: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!( + state.parent_integrations[&parent_id].state, + crate::opensymphony_orchestrator::ParentIntegrationState::Completed + ); +} + +#[tokio::test] +async fn stale_completed_parent_does_not_release_changed_hierarchy() { + let suffix = "STALE-COMPLETED"; + let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 140, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); + enqueue_successful_parent_completion(&mut scheduler, suffix, 150); + scheduler + .tick(ts(150)) + .await + .expect("terminal parent outcome should complete"); + + let mut changed_parent = tracker_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + 0, + ); changed_parent.sub_issues = vec![TrackerIssueRef { id: format!("replacement-child-{suffix}"), identifier: format!("COE-REPLACEMENT-{suffix}"), @@ -3047,6 +3885,50 @@ async fn stale_completed_parent_does_not_release_changed_hierarchy() { ); assert!(scheduler.workspace().cleaned.is_empty()); assert!(!state.terminal_orchestrator_issues.contains(&parent_id)); + + scheduler + .workspace_mut() + .cleanup_results + .push_back(Err(FakeError { + message: "cleanup remains retryable".to_owned(), + category: None, + retry_after: None, + })); + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(3_600_210)) + .await + .expect("capture should retain the completed controller generation"); + assert_eq!( + scheduler.workspace().cleanup_target_requests, + vec![LeaseResource { + issue_id: IssueId::new(format!("child-{suffix}")).expect("child id"), + repository_id: CanonicalRepositoryId::new(format!("github:repository:{suffix}")) + .expect("repository id"), + checkout_generation: format!("checkout-{suffix}"), + }], + "capture must select the completed controller's leased generation after scope changes" + ); + let error = scheduler + .replan_parent(&parent_id, ts(3_600_220)) + .await + .expect_err("operator replan must preserve incomplete cleanup state"); + assert!(error.to_string().contains("subtree cleanup is incomplete")); + let preserved: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + assert_eq!(preserved.hierarchy[&parent_id].generation, 2); + assert_eq!( + preserved.parent_integrations[&parent_id].hierarchy_generation, + 1 + ); + assert_eq!( + preserved.parent_integrations[&parent_id] + .subtree_cleanup + .as_ref() + .expect("cleanup intent") + .status, + crate::opensymphony_orchestrator::ParentSubtreeCleanupStatus::Pending + ); } #[tokio::test] @@ -3172,6 +4054,79 @@ async fn parent_release_restores_execution_and_controller_when_persistence_fails assert!(scheduler.workspace().cleaned.is_empty()); } +#[tokio::test] +async fn recovery_selects_only_the_controller_parent_generation() { + let suffix = "RECOVERY-PARENT-GENERATION"; + let (scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let active_parent = scheduler.tracker().active[0].clone(); + let parent_targets = scheduler.workspace().parent_targets.clone(); + let durable_state = scheduler + .workspace() + .durable_state + .clone() + .expect("durable state"); + let generation = serde_json::from_value::< + crate::opensymphony_orchestrator::DurableOrchestratorState, + >(durable_state.clone()) + .expect("decode durable state") + .parent_integrations[&parent_id] + .hierarchy_generation; + let recovery = |generation: u64| RecoveryRecord { + issue: normalized_issue( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "In Progress", + ), + workspace: workspace_record( + &format!("COE-PARENT-{suffix}"), + &format!("/tmp/workspaces/parents/COE-PARENT-{suffix}/{generation}"), + ), + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: None, + interrupt_reason: None, + recovered_run: None, + }; + let current_path = recovery(generation).workspace.path; + let recoveries = vec![recovery(generation), recovery(generation + 1)]; + let mut restarted = Scheduler::new( + FakeTracker { + active: vec![active_parent], + ..Default::default() + }, + FakeWorkspace { + durable_state: Some(durable_state), + recoveries, + parent_targets, + ..Default::default() + }, + FakeWorker::default(), + scheduler_config(), + ); + + restarted + .tick(ts(200)) + .await + .expect("scheduler recovery should select the durable controller generation"); + + assert_eq!( + restarted + .execution(&parent_id) + .and_then(|execution| execution.workspace()) + .map(|workspace| workspace.path.clone()), + Some(current_path), + "a newer unrelated parent root must not overwrite the controller's exact generation" + ); +} + #[tokio::test] async fn legacy_in_flight_parent_migrates_controller_before_reattachment() { let (scheduler, parent_id) = launched_parent_scheduler("LEGACY-MIGRATION").await; diff --git a/crates/opensymphony-workspace/src/error.rs b/crates/opensymphony-workspace/src/error.rs index e604fae6f..09c81e11b 100644 --- a/crates/opensymphony-workspace/src/error.rs +++ b/crates/opensymphony-workspace/src/error.rs @@ -103,6 +103,20 @@ pub enum WorkspaceError { }, #[error("failed to remove workspace {path}: {source}")] RemoveWorkspace { path: PathBuf, source: io::Error }, + #[error("workspace cleanup generation mismatch at {path}: expected {expected}, found {actual}")] + CleanupGenerationMismatch { + path: PathBuf, + expected: String, + actual: String, + }, + #[error("workspace cleanup outcome mismatch at {path}: expected {expected}, found {actual}")] + CleanupOutcomeMismatch { + path: PathBuf, + expected: String, + actual: String, + }, + #[error("missing matching cleanup tombstone for generation {generation} at {path}")] + MissingCleanupTombstone { path: PathBuf, generation: String }, #[error("checkout generation {generation} at {path} is not attachable: {reason}")] CheckoutVerification { path: PathBuf, diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index 3e1390f36..c7334ee53 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -9,7 +9,8 @@ pub use manager::{ compose_terminal_prompt, }; pub use models::{ - CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, CleanupOutcome, + CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, CleanupIntent, + CleanupOutcome, CleanupRequest, CleanupTarget, CleanupTerminalOutcome, CleanupTombstone, ConversationManifest, EnsureWorkspaceResult, HookConfig, HookDefinition, HookExecutionRecord, HookExecutionStatus, HookKind, InstructionProvenance, IssueContextArtifact, IssueDescriptor, IssueLifecycleState, IssueManifest, ParentCheckoutRequest, ParentChildCheckoutMap, diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index edcb0caff..dde850b41 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -29,7 +29,8 @@ use url::Url; use uuid::Uuid; use super::{ - CheckoutManifest, CheckoutRepository, CleanupDecision, CleanupOutcome, ConversationManifest, + CheckoutManifest, CheckoutRepository, CleanupDecision, CleanupIntent, CleanupOutcome, + CleanupRequest, CleanupTarget, CleanupTerminalOutcome, CleanupTombstone, ConversationManifest, EnsureWorkspaceResult, HookDefinition, HookExecutionRecord, HookExecutionStatus, HookKind, IssueContextArtifact, IssueDescriptor, IssueLifecycleState, IssueManifest, ParentCheckoutRequest, ParentChildCheckoutMap, ParentExecutionManifest, ParentExecutionRoot, @@ -594,6 +595,8 @@ impl WorkspaceManager { .await?; let issue_manifest = self.upsert_issue_manifest(issue, &handle).await?; debug_assert_eq!(issue_manifest.workspace_path, handle.workspace_path()); + self.remove_cleanup_tombstone(&handle, &format!("parent:{hierarchy_generation}")) + .await?; Ok(ParentExecutionRoot { handle, manifest, @@ -2369,6 +2372,10 @@ impl WorkspaceManager { }; self.bootstrap_workspace_layout(&handle).await?; let issue_manifest = self.upsert_issue_manifest(issue, &handle).await?; + if created { + self.remove_cleanup_tombstone(&handle, &format!("workspace:{}", issue.issue_id)) + .await?; + } Ok(EnsureWorkspaceResult { handle, @@ -4664,10 +4671,15 @@ impl WorkspaceManager { workspace: &WorkspaceHandle, state: IssueLifecycleState, ) -> Result { - self.cleanup_with_terminal_removal( + let generation = self.cleanup_generation(workspace).await?; + self.cleanup_with_request( workspace, state, - self.config.cleanup.remove_terminal_workspaces, + CleanupRequest { + generation, + outcome: CleanupTerminalOutcome::Succeeded, + remove: self.config.cleanup.remove_terminal_workspaces, + }, ) .await } @@ -4679,24 +4691,134 @@ impl WorkspaceManager { &self, workspace: &WorkspaceHandle, ) -> Result { - self.cleanup_with_terminal_removal(workspace, IssueLifecycleState::Terminal, true) - .await + let generation = self.cleanup_generation(workspace).await?; + self.cleanup_with_request( + workspace, + IssueLifecycleState::Terminal, + CleanupRequest { + generation, + outcome: CleanupTerminalOutcome::Failed, + remove: true, + }, + ) + .await + } + + pub async fn cleanup_target( + &self, + target: &CleanupTarget, + ) -> Result { + let workspace = Self::workspace_handle_for_cleanup_target(target); + self.cleanup_with_request( + &workspace, + IssueLifecycleState::Terminal, + CleanupRequest { + generation: target.generation.clone(), + outcome: target.outcome, + remove: true, + }, + ) + .await + } + + pub async fn cleanup_target_deletion_started( + &self, + target: &CleanupTarget, + ) -> Result { + let workspace = Self::workspace_handle_for_cleanup_target(target); + let request = CleanupRequest { + generation: target.generation.clone(), + outcome: target.outcome, + remove: true, + }; + Ok(self + .matching_cleanup_tombstone(&workspace, &request) + .await? + .is_some_and(|tombstone| tombstone.deleted_at.is_none())) } - async fn cleanup_with_terminal_removal( + pub async fn prepare_cleanup_target( + &self, + target: &CleanupTarget, + ) -> Result<(), WorkspaceError> { + let workspace = Self::workspace_handle_for_cleanup_target(target); + let request = CleanupRequest { + generation: target.generation.clone(), + outcome: target.outcome, + remove: true, + }; + if !path_exists(workspace.workspace_path()).await? { + return self + .matching_cleanup_tombstone(&workspace, &request) + .await? + .map(|_| ()) + .ok_or_else(|| WorkspaceError::MissingCleanupTombstone { + path: workspace.workspace_path().to_path_buf(), + generation: request.generation, + }); + } + self.validate_workspace_handle(&workspace).await?; + if self + .matching_cleanup_tombstone(&workspace, &request) + .await? + .is_some_and(|tombstone| tombstone.deleted_at.is_none()) + { + return Ok(()); + } + let actual_generation = self.cleanup_generation(&workspace).await?; + if actual_generation != request.generation { + return Err(WorkspaceError::CleanupGenerationMismatch { + path: workspace.workspace_path().to_path_buf(), + expected: request.generation, + actual: actual_generation, + }); + } + self.prepare_removal(&workspace, &request).await?; + Ok(()) + } + + fn workspace_handle_for_cleanup_target(target: &CleanupTarget) -> WorkspaceHandle { + let mut workspace = WorkspaceHandle::new( + target.issue_id.clone(), + target.identifier.clone(), + target.workspace.workspace_key.to_string(), + target.workspace.path.clone(), + ); + if !target.generation.starts_with("parent:") && !target.generation.starts_with("workspace:") + { + workspace = workspace.with_checkout_generation(target.generation.clone()); + } + workspace + } + + pub async fn cleanup_with_request( &self, workspace: &WorkspaceHandle, state: IssueLifecycleState, - remove_terminal_workspaces: bool, + request: CleanupRequest, ) -> Result { if !path_exists(workspace.workspace_path()).await? { + if state != IssueLifecycleState::Terminal || !request.remove { + return Err(WorkspaceError::MissingCleanupTombstone { + path: workspace.workspace_path().to_path_buf(), + generation: request.generation, + }); + } + let mut tombstone = self + .matching_cleanup_tombstone(workspace, &request) + .await? + .ok_or_else(|| WorkspaceError::MissingCleanupTombstone { + path: workspace.workspace_path().to_path_buf(), + generation: request.generation.clone(), + })?; + if tombstone.deleted_at.is_none() { + tombstone.deleted_at = Some(Utc::now()); + self.write_cleanup_tombstone(workspace, &tombstone).await?; + } return Ok(CleanupOutcome { - decision: if state == IssueLifecycleState::Terminal && remove_terminal_workspaces { - CleanupDecision::Remove - } else { - CleanupDecision::Retain - }, - before_remove: None, + decision: CleanupDecision::Remove, + before_remove: tombstone.before_remove.clone(), + tombstone: Some(tombstone), }); } @@ -4705,22 +4827,15 @@ impl WorkspaceManager { return Ok(CleanupOutcome { decision: CleanupDecision::Retain, before_remove: None, + tombstone: None, }); } - let before_remove = match self.execute_hook(HookKind::BeforeRemove, workspace).await { - Ok(record) => record, - Err(failure) => Some(failure.record), - }; - let decision = if remove_terminal_workspaces { - CleanupDecision::Remove - } else { - CleanupDecision::Retain - }; - - if decision == CleanupDecision::Remove { - self.unregister_parent_integration_worktrees(workspace) - .await?; + if request.remove + && let Some(mut tombstone) = + self.matching_cleanup_tombstone(workspace, &request).await? + && tombstone.deleted_at.is_none() + { match fs::remove_dir_all(workspace.workspace_path()).await { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} @@ -4731,17 +4846,229 @@ impl WorkspaceManager { }); } } + tombstone.deleted_at = Some(Utc::now()); + self.write_cleanup_tombstone(workspace, &tombstone).await?; + return Ok(CleanupOutcome { + decision: CleanupDecision::Remove, + before_remove: tombstone.before_remove.clone(), + tombstone: Some(tombstone), + }); + } + + let actual_generation = self.cleanup_generation(workspace).await?; + if actual_generation != request.generation { + return Err(WorkspaceError::CleanupGenerationMismatch { + path: workspace.workspace_path().to_path_buf(), + expected: request.generation, + actual: actual_generation, + }); + } + let decision = if request.remove { + CleanupDecision::Remove + } else { + CleanupDecision::Retain + }; + + if decision == CleanupDecision::Retain { + let before_remove = match self.execute_hook(HookKind::BeforeRemove, workspace).await { + Ok(record) => record, + Err(failure) => Some(failure.record), + }; + return Ok(CleanupOutcome { + decision, + before_remove, + tombstone: None, + }); } + let mut run_manifest = self.prepare_removal(workspace, &request).await?; + let deletion_started_at = run_manifest + .cleanup_intent + .as_ref() + .and_then(|intent| intent.deletion_started_at) + .unwrap_or_else(Utc::now); + { + let intent = run_manifest + .cleanup_intent + .as_mut() + .expect("cleanup intent was initialized"); + intent.deletion_started_at = Some(deletion_started_at); + intent.last_error = None; + } + self.write_run_manifest(workspace, &run_manifest).await?; + let mut tombstone = CleanupTombstone { + schema_version: 1, + issue_id: workspace.issue_id().to_owned(), + identifier: workspace.identifier().to_owned(), + sanitized_workspace_key: workspace.workspace_key().to_owned(), + workspace_path: workspace.workspace_path().to_path_buf(), + generation: request.generation, + outcome: request.outcome, + deletion_started_at, + before_remove: run_manifest + .cleanup_intent + .as_ref() + .and_then(|intent| intent.before_remove.clone()), + deleted_at: None, + }; + self.write_cleanup_tombstone(workspace, &tombstone).await?; + match fs::remove_dir_all(workspace.workspace_path()).await { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(WorkspaceError::RemoveWorkspace { + path: workspace.workspace_path().to_path_buf(), + source: error, + }); + } + } + tombstone.deleted_at = Some(Utc::now()); + self.write_cleanup_tombstone(workspace, &tombstone).await?; Ok(CleanupOutcome { decision, - before_remove, + before_remove: run_manifest + .cleanup_intent + .and_then(|intent| intent.before_remove), + tombstone: Some(tombstone), }) } + async fn prepare_removal( + &self, + workspace: &WorkspaceHandle, + request: &CleanupRequest, + ) -> Result { + let mut run_manifest = match self.load_run_manifest(workspace).await? { + Some(manifest) => manifest, + None => RunManifest::new( + workspace, + &RunDescriptor::new(format!("cleanup:{}", request.generation), 0), + ), + }; + match run_manifest.cleanup_intent.as_ref() { + Some(intent) if intent.generation != request.generation => { + return Err(WorkspaceError::CleanupGenerationMismatch { + path: workspace.workspace_path().to_path_buf(), + expected: request.generation.clone(), + actual: intent.generation.clone(), + }); + } + Some(intent) if intent.outcome != request.outcome => { + return Err(WorkspaceError::CleanupOutcomeMismatch { + path: workspace.workspace_path().to_path_buf(), + expected: format!("{:?}", request.outcome), + actual: format!("{:?}", intent.outcome), + }); + } + Some(_) => {} + None => { + run_manifest.cleanup_intent = Some(CleanupIntent::new(request)); + self.write_run_manifest(workspace, &run_manifest).await?; + } + } + + let before_remove_recorded = run_manifest + .cleanup_intent + .as_ref() + .and_then(|intent| intent.before_remove.as_ref()) + .is_some(); + if !before_remove_recorded { + let mut run_before_remove = true; + if let Some(hook) = self.hook_definition(HookKind::BeforeRemove).cloned() { + let started_at = Utc::now(); + match self + .resolve_hook_cwd(workspace, HookKind::BeforeRemove, &hook) + .await + { + Ok(cwd) => { + run_manifest + .cleanup_intent + .as_mut() + .expect("cleanup intent was initialized") + .before_remove = Some(HookExecutionRecord { + kind: HookKind::BeforeRemove, + command: hook.command, + cwd, + best_effort: true, + status: HookExecutionStatus::Failed, + started_at, + finished_at: started_at, + duration_ms: 0, + exit_code: None, + stdout: String::new(), + stderr: "hook outcome is indeterminate after an interrupted cleanup; the hook will not be repeated" + .to_owned(), + }); + self.write_run_manifest(workspace, &run_manifest).await?; + } + Err(failure) => { + run_manifest.hooks.push(failure.record.clone()); + let intent = run_manifest + .cleanup_intent + .as_mut() + .expect("cleanup intent was initialized"); + intent.before_remove = Some(failure.record); + intent.retry_count = intent.retry_count.saturating_add(1); + intent.last_error = Some(failure.error.to_string()); + self.write_run_manifest(workspace, &run_manifest).await?; + run_before_remove = false; + } + } + } + if run_before_remove { + match self.execute_hook(HookKind::BeforeRemove, workspace).await { + Ok(record) => { + if let Some(record) = record { + run_manifest.hooks.push(record.clone()); + run_manifest + .cleanup_intent + .as_mut() + .expect("cleanup intent was initialized") + .before_remove = Some(record); + } + let intent = run_manifest + .cleanup_intent + .as_mut() + .expect("cleanup intent was initialized"); + intent.last_error = None; + self.write_run_manifest(workspace, &run_manifest).await?; + } + Err(failure) => { + run_manifest.hooks.push(failure.record.clone()); + let intent = run_manifest + .cleanup_intent + .as_mut() + .expect("cleanup intent was initialized"); + intent.before_remove = Some(failure.record); + intent.retry_count = intent.retry_count.saturating_add(1); + intent.last_error = Some(failure.error.to_string()); + self.write_run_manifest(workspace, &run_manifest).await?; + } + } + } + } + + if let Err(error) = self + .unregister_parent_integration_worktrees(workspace, &mut run_manifest) + .await + { + let intent = run_manifest + .cleanup_intent + .as_mut() + .expect("cleanup intent was initialized"); + intent.retry_count = intent.retry_count.saturating_add(1); + intent.last_error = Some(error.to_string()); + self.write_run_manifest(workspace, &run_manifest).await?; + return Err(error); + } + + Ok(run_manifest) + } + async fn unregister_parent_integration_worktrees( &self, workspace: &WorkspaceHandle, + run_manifest: &mut RunManifest, ) -> Result<(), WorkspaceError> { let Some(_manifest) = self .load_manifest::(workspace, &workspace.parent_manifest_path()) @@ -4759,6 +5086,13 @@ impl WorkspaceManager { ) })?; for record in checkout_map.repositories.values() { + if run_manifest.cleanup_intent.as_ref().is_some_and(|intent| { + intent + .removed_integration_worktrees + .contains(&record.checkout_handle) + }) { + continue; + } let source = self.validate_parent_retained_checkouts(record).await?; let integration = resolve_path_within_root(workspace.workspace_path(), &record.relative_path)?; @@ -4782,10 +5116,174 @@ impl WorkspaceManager { ) .await?; } + run_manifest + .cleanup_intent + .as_mut() + .expect("parent worktree cleanup requires an initialized intent") + .removed_integration_worktrees + .insert(record.checkout_handle.clone()); + self.write_run_manifest(workspace, run_manifest).await?; } Ok(()) } + async fn cleanup_generation( + &self, + workspace: &WorkspaceHandle, + ) -> Result { + if workspace.checkout_generation().is_some() { + let checkout = self + .load_manifest::(workspace, &workspace.checkout_manifest_path()) + .await? + .ok_or_else(|| { + checkout_verification( + workspace.workspace_path(), + "cleanup target checkout manifest is missing", + ) + })?; + let issue = self + .load_manifest::(workspace, &workspace.issue_manifest_path()) + .await? + .ok_or_else(|| { + checkout_verification( + workspace.workspace_path(), + "cleanup target issue manifest is missing", + ) + })?; + if checkout.schema_version != 1 + || checkout.quarantined + || issue.issue_id != workspace.issue_id() + || issue.identifier != workspace.identifier() + || !issue_manifest_claims_workspace(workspace, &issue) + || !issue_manifest_owns_checkout(workspace, &issue, &checkout) + { + return Err(checkout_verification( + workspace.workspace_path(), + "cleanup target ownership manifests are inconsistent", + )); + } + return Ok(checkout.generation); + } + if path_exists(workspace.workspace_path()).await? + && let Some(parent) = self + .load_manifest::( + workspace, + &workspace.parent_manifest_path(), + ) + .await? + { + if parent.schema_version != 1 + || parent.parent_issue_id != workspace.issue_id() + || parent.parent_identifier != workspace.identifier() + || normalize_absolute_path(&parent.workspace_path)? != workspace.workspace_path() + { + return Err(checkout_verification( + workspace.workspace_path(), + "parent cleanup target manifest is inconsistent", + )); + } + return Ok(format!("parent:{}", parent.hierarchy_generation)); + } + if path_exists(workspace.workspace_path()).await? { + let issue = self + .load_manifest::(workspace, &workspace.issue_manifest_path()) + .await? + .ok_or_else(|| { + checkout_verification( + workspace.workspace_path(), + "cleanup target issue manifest is missing", + ) + })?; + if issue.issue_id != workspace.issue_id() + || issue.identifier != workspace.identifier() + || !issue_manifest_claims_workspace(workspace, &issue) + { + return Err(checkout_verification( + workspace.workspace_path(), + "cleanup target issue manifest is inconsistent", + )); + } + return Ok(format!("workspace:{}", workspace.issue_id())); + } + Err(WorkspaceError::MissingCleanupTombstone { + path: workspace.workspace_path().to_path_buf(), + generation: "unknown".to_owned(), + }) + } + + async fn matching_cleanup_tombstone( + &self, + workspace: &WorkspaceHandle, + request: &CleanupRequest, + ) -> Result, WorkspaceError> { + if !path_exists(&self.config.root).await? { + return Ok(None); + } + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root.clone()); + let path = self.cleanup_tombstone_path(&canonical_root, workspace, &request.generation); + let tombstone = self + .load_manifest::(&handle, &path) + .await?; + Ok(tombstone.filter(|tombstone| { + tombstone.schema_version == 1 + && tombstone.issue_id == workspace.issue_id() + && tombstone.identifier == workspace.identifier() + && tombstone.sanitized_workspace_key == workspace.workspace_key() + && tombstone.workspace_path == workspace.workspace_path() + && tombstone.generation == request.generation + && tombstone.outcome == request.outcome + })) + } + + async fn write_cleanup_tombstone( + &self, + workspace: &WorkspaceHandle, + tombstone: &CleanupTombstone, + ) -> Result<(), WorkspaceError> { + self.create_directory(&self.config.root).await?; + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let handle = self.orchestrator_state_handle(canonical_root.clone()); + let path = self.cleanup_tombstone_path(&canonical_root, workspace, &tombstone.generation); + let mut sanitized = tombstone.clone(); + if let Some(hook) = sanitized.before_remove.as_mut() { + redact_hook_execution_record(hook); + } + self.write_manifest_atomically(&handle, &path, &sanitized) + .await + } + + async fn remove_cleanup_tombstone( + &self, + workspace: &WorkspaceHandle, + generation: &str, + ) -> Result<(), WorkspaceError> { + let canonical_root = self.canonicalize_path(&self.config.root).await?; + let path = self.cleanup_tombstone_path(&canonical_root, workspace, generation); + match fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(WorkspaceError::WriteManifest { path, source }), + } + } + + fn cleanup_tombstone_path( + &self, + canonical_root: &Path, + workspace: &WorkspaceHandle, + generation: &str, + ) -> PathBuf { + let mut hasher = Sha256::new(); + hasher.update(workspace.issue_id().as_bytes()); + hasher.update([0]); + hasher.update(workspace.workspace_path().as_os_str().as_encoded_bytes()); + hasher.update([0]); + hasher.update(generation.as_bytes()); + canonical_root + .join(".opensymphony-cleanup-tombstones") + .join(format!("{:x}.json", hasher.finalize())) + } + pub async fn load_issue_manifest( &self, workspace: &WorkspaceHandle, @@ -5053,6 +5551,132 @@ impl WorkspaceManager { } } + let parents_root = self.config.root.join("parents"); + self.reject_symlinked_path_components(&self.config.root, Path::new("parents")) + .await?; + let mut parent_keys = match fs::read_dir(&parents_root).await { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(workspaces), + Err(source) => { + return Err(WorkspaceError::ReadDirectory { + path: parents_root, + source, + }); + } + }; + while let Some(parent_key) = + parent_keys + .next_entry() + .await + .map_err(|source| WorkspaceError::ReadDirectory { + path: parents_root.clone(), + source, + })? + { + if !parent_key + .file_type() + .await + .map_err(|source| WorkspaceError::ReadDirectory { + path: parent_key.path(), + source, + })? + .is_dir() + { + continue; + } + let parent_key_path = parent_key.path(); + let mut generations = fs::read_dir(&parent_key_path).await.map_err(|source| { + WorkspaceError::ReadDirectory { + path: parent_key_path.clone(), + source, + } + })?; + while let Some(generation) = + generations + .next_entry() + .await + .map_err(|source| WorkspaceError::ReadDirectory { + path: parent_key_path.clone(), + source, + })? + { + if !generation + .file_type() + .await + .map_err(|source| WorkspaceError::ReadDirectory { + path: generation.path(), + source, + })? + .is_dir() + { + continue; + } + let path = generation.path(); + let loaded = match self.load_workspace_from_directory(&path).await { + Ok(loaded) => loaded, + Err(error) => { + tracing::warn!( + path = %path.display(), + %error, + "skipping invalid parent execution root during recovery" + ); + continue; + } + }; + let Some((handle, issue)) = loaded else { + continue; + }; + let parent = match self + .load_manifest::( + &handle, + &handle.parent_manifest_path(), + ) + .await + { + Ok(Some(parent)) => parent, + Ok(None) => continue, + Err(error) => { + tracing::warn!( + path = %path.display(), + %error, + "skipping parent execution root with invalid manifest during recovery" + ); + continue; + } + }; + let expected_generation = generation + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()); + let expected_key = parent_key.file_name(); + let generated_key = parent_workspace_key(&issue.identifier, &issue.issue_id).ok(); + if parent.schema_version != 1 + || parent.parent_issue_id != issue.issue_id + || parent.parent_identifier != issue.identifier + || handle.checkout_generation().is_some() + || expected_generation != Some(parent.hierarchy_generation) + || expected_key.to_str() != Some(handle.workspace_key()) + || generated_key.as_deref() != Some(handle.workspace_key()) + || normalize_absolute_path(&parent.workspace_path) + .ok() + .as_deref() + != Some(handle.workspace_path()) + || parent.child_checkout_map != Path::new("child-checkouts.json") + || parent.integration_plan != Path::new("integration-plan.md") + || parent.evidence_directory != Path::new("evidence") + || parent.repositories_directory != Path::new("repositories") + || path_exists(&handle.workspace_path().join(".git")).await? + { + tracing::warn!( + path = %path.display(), + "skipping parent execution root with inconsistent identity during recovery" + ); + continue; + } + workspaces.push((handle, issue)); + } + } + Ok(workspaces) } @@ -5091,9 +5715,13 @@ impl WorkspaceManager { .as_deref() .map(redact_runtime_diagnostic); for hook in &mut sanitized.hooks { - hook.command = redact_runtime_diagnostic(&hook.command); - hook.stdout = redact_runtime_diagnostic(&hook.stdout); - hook.stderr = redact_runtime_diagnostic(&hook.stderr); + redact_hook_execution_record(hook); + } + if let Some(cleanup) = sanitized.cleanup_intent.as_mut() { + cleanup.last_error = cleanup.last_error.as_deref().map(redact_runtime_diagnostic); + if let Some(hook) = cleanup.before_remove.as_mut() { + redact_hook_execution_record(hook); + } } self.write_manifest_atomically(workspace, &workspace.run_manifest_path(), &sanitized) .await @@ -7285,6 +7913,12 @@ fn issue_manifest_owns_checkout( && issue_manifest_binding_matches_checkout(manifest, checkout) } +fn redact_hook_execution_record(record: &mut HookExecutionRecord) { + record.command = redact_runtime_diagnostic(&record.command); + record.stdout = redact_runtime_diagnostic(&record.stdout); + record.stderr = redact_runtime_diagnostic(&record.stderr); +} + fn ensure_descendant(root: &Path, candidate: &Path) -> Result<(), WorkspaceError> { if candidate.starts_with(root) { Ok(()) diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index 1787c4670..449d05602 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, fmt, path::{Path, PathBuf}, time::Duration, @@ -8,7 +8,7 @@ use std::{ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::opensymphony_domain::{RepositoryBinding, RepositoryBindingOutcome}; +use crate::opensymphony_domain::{RepositoryBinding, RepositoryBindingOutcome, WorkspaceRecord}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckoutRepository { @@ -53,8 +53,8 @@ pub fn environment_variable_names_equal(left: &str, right: &str) -> bool { pub fn checkout_credential_environment_variables( repositories: &BTreeMap, -) -> std::collections::BTreeSet { - let mut variables = std::collections::BTreeSet::new(); +) -> BTreeSet { + let mut variables = BTreeSet::new(); for repository in repositories.values() { if let Some(variable) = repository.credential_env.as_ref() { variables.insert(variable.clone()); @@ -944,10 +944,83 @@ pub enum CleanupDecision { Remove, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CleanupTerminalOutcome { + Succeeded, + Failed, + Canceled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CleanupRequest { + pub generation: String, + pub outcome: CleanupTerminalOutcome, + pub remove: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CleanupTarget { + pub issue_id: String, + pub identifier: String, + pub workspace: WorkspaceRecord, + pub generation: String, + pub outcome: CleanupTerminalOutcome, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CleanupIntent { + pub generation: String, + pub outcome: CleanupTerminalOutcome, + pub requested_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before_remove: Option, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub removed_integration_worktrees: BTreeSet, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deletion_started_at: Option>, + #[serde(default)] + pub retry_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +impl CleanupIntent { + pub fn new(request: &CleanupRequest) -> Self { + Self { + generation: request.generation.clone(), + outcome: request.outcome, + requested_at: Utc::now(), + before_remove: None, + removed_integration_worktrees: BTreeSet::new(), + deletion_started_at: None, + retry_count: 0, + last_error: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CleanupTombstone { + pub schema_version: u32, + pub issue_id: String, + pub identifier: String, + pub sanitized_workspace_key: String, + pub workspace_path: PathBuf, + pub generation: String, + pub outcome: CleanupTerminalOutcome, + pub deletion_started_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub before_remove: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CleanupOutcome { pub decision: CleanupDecision, pub before_remove: Option, + pub tombstone: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1088,6 +1161,8 @@ pub struct RunManifest { pub status_detail: Option, #[serde(default)] pub hooks: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_intent: Option, } impl RunManifest { @@ -1117,6 +1192,7 @@ impl RunManifest { updated_at: now, status_detail: None, hooks: Vec::new(), + cleanup_intent: None, } } } diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index e79c3cc68..f42e47747 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -2,16 +2,16 @@ use std::{collections::BTreeMap, process::Command, time::Duration}; use crate::opensymphony_domain::{ CanonicalRepositoryId, RepositoryBinding, RepositoryBindingOutcome, RepositoryIdentity, - SafeRemoteFingerprint, + SafeRemoteFingerprint, WorkspaceKey, WorkspaceRecord, }; use crate::opensymphony_workspace::{ - CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, ConversationManifest, - HookConfig, HookDefinition, HookExecutionRecord, HookExecutionStatus, HookKind, - IssueContextArtifact, IssueDescriptor, IssueLifecycleState, ParentCheckoutRequest, - ParentRuntimeDescriptor, PromptCaptureDescriptor, PromptKind, RunDescriptor, RunManifest, - RunStatus, SessionContextArtifact, WorkspaceError, WorkspaceManager, WorkspaceManagerConfig, - compose_parent_continuation_prompt, compose_parent_prompt, compose_terminal_prompt, - parent_workspace_key, + CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, CleanupRequest, + CleanupTarget, CleanupTerminalOutcome, ConversationManifest, HookConfig, HookDefinition, + HookExecutionRecord, HookExecutionStatus, HookKind, IssueContextArtifact, IssueDescriptor, + IssueLifecycleState, ParentCheckoutRequest, ParentRuntimeDescriptor, PromptCaptureDescriptor, + PromptKind, RunDescriptor, RunManifest, RunStatus, SessionContextArtifact, WorkspaceError, + WorkspaceManager, WorkspaceManagerConfig, compose_parent_continuation_prompt, + compose_parent_prompt, compose_terminal_prompt, parent_workspace_key, }; use serde_json::json; use tempfile::TempDir; @@ -1092,11 +1092,53 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( .join(&checkout.relative_path) }) .collect::>(); + let cleanup_target = CleanupTarget { + issue_id: parent.issue_id.clone(), + identifier: parent.identifier.clone(), + workspace: WorkspaceRecord { + path: cleanup_parent.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(cleanup_parent.handle.workspace_key().to_owned()) + .expect("managed key"), + created_now: false, + created_at: None, + updated_at: None, + last_seen_tracker_refresh_at: None, + }, + generation: "parent:9".to_owned(), + outcome: CleanupTerminalOutcome::Succeeded, + }; + let stale_child_target = CleanupTarget { + issue_id: child_a1.handle.issue_id().to_owned(), + identifier: child_a1.handle.identifier().to_owned(), + workspace: WorkspaceRecord { + path: child_a1.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(child_a1.handle.workspace_key().to_owned()) + .expect("managed key"), + created_now: false, + created_at: None, + updated_at: None, + last_seen_tracker_refresh_at: None, + }, + generation: "stale-retained-generation".to_owned(), + outcome: CleanupTerminalOutcome::Succeeded, + }; + assert!(matches!( + manager.cleanup_target(&stale_child_target).await, + Err(WorkspaceError::CleanupGenerationMismatch { actual, .. }) + if actual == child_a1.handle.checkout_generation().expect("generation") + )); + assert!( + child_a1.handle.workspace_path().exists(), + "an old retained generation must not delete the current checkout" + ); manager - .cleanup_failed_terminal_workspace(&cleanup_parent.handle) + .prepare_cleanup_target(&cleanup_target) .await - .expect("terminal parent cleanup should unregister integration worktrees"); - assert!(!cleanup_parent.handle.workspace_path().exists()); + .expect("parent preparation should unregister integration worktrees"); + assert!( + cleanup_parent.handle.workspace_path().exists(), + "preparation must preserve the parent root until descendants are cleaned" + ); let registered_worktrees = [ child_a1.handle.workspace_path(), child_b.handle.workspace_path(), @@ -1112,6 +1154,11 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( "terminal cleanup must remove the Git worktree registration" ); } + manager + .cleanup_target(&cleanup_target) + .await + .expect("terminal parent cleanup should remove its prepared root"); + assert!(!cleanup_parent.handle.workspace_path().exists()); manager .prepare_parent_execution_root(&parent, 9, repeat_requests.clone()) .await @@ -1156,6 +1203,15 @@ async fn parent_execution_root_supports_no_required_child_checkouts() { .await .expect("a parent with no required child checkouts should still get an execution root"); + let discovered = manager + .list_all_workspaces() + .await + .expect("nested parent roots should be discoverable for recovery"); + assert!(discovered.iter().any(|(handle, manifest)| { + handle.workspace_path() == prepared.handle.workspace_path() + && manifest.issue_id == parent.issue_id + })); + assert!(!prepared.handle.workspace_path().join(".git").exists()); assert!(prepared.child_checkout_map.repositories.is_empty()); assert!( @@ -3928,6 +3984,219 @@ async fn terminal_cleanup_can_delete_workspace() { ); } +#[tokio::test] +async fn terminal_cleanup_receipts_best_effort_hook_and_accepts_only_its_generation_tombstone() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let workspace_root = temp_dir.path().join("workspaces"); + let manager = WorkspaceManager::new(manager_config( + &workspace_root, + HookConfig { + before_remove: Some(HookDefinition::shell( + "grep -q '\"before_remove\"' .opensymphony/run.json || exit 9; printf 'token=hook-secret\\n' >> ../hook-count; if [ -f .cleanup-ready ]; then exit 0; else touch .cleanup-ready; exit 7; fi", + )), + ..HookConfig::default() + }, + CleanupConfig { + remove_terminal_workspaces: true, + }, + )) + .expect("manager should build"); + let issue = sample_issue("COE-263-terminal-retry"); + let ensured = manager + .ensure(&issue) + .await + .expect("workspace should exist"); + let request = CleanupRequest { + generation: format!("workspace:{}", issue.issue_id), + outcome: CleanupTerminalOutcome::Succeeded, + remove: true, + }; + + manager + .prepare_cleanup_target(&CleanupTarget { + issue_id: issue.issue_id.clone(), + identifier: issue.identifier.clone(), + workspace: WorkspaceRecord { + path: ensured.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(ensured.handle.workspace_key().to_owned()) + .expect("managed key"), + created_now: false, + created_at: None, + updated_at: None, + last_seen_tracker_refresh_at: None, + }, + generation: request.generation.clone(), + outcome: request.outcome, + }) + .await + .expect("a best-effort hook failure must not block cleanup preparation"); + assert!(ensured.handle.workspace_path().exists()); + let failed_manifest = manager + .load_run_manifest(&ensured.handle) + .await + .expect("manifest should load") + .expect("cleanup intent should exist"); + assert_eq!( + failed_manifest.cleanup_intent.expect("intent").retry_count, + 1 + ); + let tombstone_directory = workspace_root.join(".opensymphony-cleanup-tombstones"); + std::fs::write(&tombstone_directory, "blocked") + .expect("simulate a tombstone permission/path failure"); + manager + .cleanup_with_request( + &ensured.handle, + IssueLifecycleState::Terminal, + request.clone(), + ) + .await + .expect_err("tombstone failure must leave the prepared workspace retryable"); + assert!(ensured.handle.workspace_path().exists()); + std::fs::remove_file(&tombstone_directory).expect("repair tombstone directory"); + + let removed = manager + .cleanup_with_request( + &ensured.handle, + IssueLifecycleState::Terminal, + request.clone(), + ) + .await + .expect("retry should remove the intended generation"); + assert!( + removed + .tombstone + .as_ref() + .and_then(|t| t.deleted_at) + .is_some() + ); + let tombstone_path = std::fs::read_dir(&tombstone_directory) + .expect("tombstone directory") + .next() + .expect("tombstone entry") + .expect("tombstone path") + .path(); + let mut incomplete: serde_json::Value = + serde_json::from_slice(&std::fs::read(&tombstone_path).expect("read completed tombstone")) + .expect("decode tombstone"); + let completed_tombstone = + std::fs::read_to_string(&tombstone_path).expect("read tombstone text"); + assert!(!completed_tombstone.contains("hook-secret")); + assert!(completed_tombstone.contains("[redacted]")); + incomplete["deleted_at"] = serde_json::Value::Null; + std::fs::write( + &tombstone_path, + serde_json::to_vec_pretty(&incomplete).expect("encode incomplete tombstone"), + ) + .expect("simulate a crash after deletion and before completion receipt"); + std::fs::create_dir_all(ensured.handle.metadata_dir()) + .expect("simulate a partially removed workspace root"); + std::fs::write(ensured.handle.workspace_path().join("undeleted"), "blocked") + .expect("partial deletion should leave visible residue"); + let resumed = manager + .cleanup_with_request(&ensured.handle, IssueLifecycleState::Terminal, request) + .await + .expect("matching tombstone should resume partial deletion"); + assert!( + resumed + .tombstone + .as_ref() + .and_then(|tombstone| tombstone.deleted_at) + .is_some(), + "restart should complete a deletion-started tombstone" + ); + assert!( + resumed.before_remove.is_some(), + "the external tombstone must preserve the successful hook receipt" + ); + assert!(!ensured.handle.workspace_path().exists()); + assert_eq!( + std::fs::read_to_string(workspace_root.join("hook-count")) + .expect("hook count") + .lines() + .count(), + 1, + "a best-effort hook receipt must not be rerun after deletion starts" + ); + + let recreated = manager + .ensure(&issue) + .await + .expect("the issue workspace may be recreated after cleanup"); + std::fs::remove_dir_all(recreated.handle.workspace_path()) + .expect("simulate deletion of the recreated generation"); + let stale = manager + .cleanup_with_request( + &recreated.handle, + IssueLifecycleState::Terminal, + CleanupRequest { + generation: format!("workspace:{}", issue.issue_id), + outcome: CleanupTerminalOutcome::Succeeded, + remove: true, + }, + ) + .await + .expect_err("recreation must invalidate the earlier tombstone"); + assert!(matches!( + stale, + WorkspaceError::MissingCleanupTombstone { .. } + )); +} + +#[tokio::test] +async fn missing_workspace_without_matching_generation_tombstone_is_not_cleanup_success() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let workspace_root = temp_dir.path().join("workspaces"); + let manager = WorkspaceManager::new(manager_config( + &workspace_root, + HookConfig::default(), + CleanupConfig { + remove_terminal_workspaces: true, + }, + )) + .expect("manager should build"); + let issue = sample_issue("COE-263-missing-no-tombstone"); + let ensured = manager + .ensure(&issue) + .await + .expect("workspace should exist"); + let mismatch = manager + .cleanup_with_request( + &ensured.handle, + IssueLifecycleState::Terminal, + CleanupRequest { + generation: "workspace:other-generation".to_owned(), + outcome: CleanupTerminalOutcome::Succeeded, + remove: true, + }, + ) + .await + .expect_err("a live workspace must reject another generation"); + assert!(matches!( + mismatch, + WorkspaceError::CleanupGenerationMismatch { .. } + )); + tokio::fs::remove_dir_all(ensured.handle.workspace_path()) + .await + .expect("simulate external deletion"); + + let error = manager + .cleanup_with_request( + &ensured.handle, + IssueLifecycleState::Terminal, + CleanupRequest { + generation: format!("workspace:{}", issue.issue_id), + outcome: CleanupTerminalOutcome::Succeeded, + remove: true, + }, + ) + .await + .expect_err("missing path without a tombstone must remain visible"); + assert!(matches!( + error, + WorkspaceError::MissingCleanupTombstone { .. } + )); +} + #[tokio::test] async fn hook_cwd_override_cannot_escape_workspace() { let temp_dir = TempDir::new().expect("temp dir should exist"); diff --git a/docs/architecture.md b/docs/architecture.md index 053a93689..50246fed4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -187,7 +187,46 @@ instructions remain in the bound conversation rather than being replayed. Terminal success is gated by the durable controller's completed state in both live and recovery release paths. Completed parent roots and their descendant leases remain durable for capture retry; OSYM-893 consumes that terminal state -for ordered worktree cleanup and lease release. +for ordered worktree cleanup and lease release. After automatic capture commits +all selected parent capsules, the run loop asks the scheduler to persist a +generation-bound subtree cleanup intent. Cleanup first archives the stopped +harness conversation and asks the workspace manager to receipt the +`before_remove` hook and detach every parent integration worktree through Git. +Only then does the scheduler release that parent's lease owners and remove +unleased descendant generations from deepest to shallowest, followed by the +non-Git parent root. A lease owned by another ancestor or by an unexpired +diagnostic hold continues to block its generation. A claimed, running, or +retry-queued child bound to the exact retained generation also blocks deletion +while that execution still owns the workspace. +Every pending cleanup tick refreshes the full tracker snapshot first. A newly +active descendant with an unresolved or matching workspace generation fences +deletion; an already resolved newer generation does not retain the old target. + +The scheduler receipts every prepared or deleted target in the durable parent +controller. Workspace run manifests hold the hook and integration-worktree +receipts, while root-level generation tombstones bracket recursive deletion. +Lease releases and final completion transitions roll back in memory when their +atomic state writes fail, and the workspace manager persists an attempted hook +fence before `before_remove` can perform side effects. Restart repeats only an +incomplete step without rerunning an indeterminate hook attempt. Generation +cleanup revokes only the matching memory bearer, so a newer run for the same +issue keeps its authorization. A missing path is successful only when +the tombstone matches its issue, workspace key, path, terminal outcome, and +generation. Failed and canceled parents use the existing failed-workspace +cleanup semantics without changing terminal classification: `retain_failed` +applies to failed parents, while canceled parents continue cleanup. Reopened +parents and operator replans cannot replace a controller until its acknowledged +cleanup completes. Capture selects descendant leases owned by that controller's +generation even if the observed hierarchy has already advanced, and restart +recovery selects only the parent root matching the controller's durable +hierarchy generation. +Generation-bound OpenHands cleanup, including a parent root, also requires its +conversation store before workspace preparation can proceed. Missing +conversation evidence fails closed unless the run manifest proves preparation +failed without ever recording a conversation binding. +The completed cleanup intent remains in orchestrator state after the parent root +is removed and seeds automatic-capture completion after daemon restart, so a +terminal parent is not routed and captured again without first reopening. ### 3.2 OpenHands is the execution adapter diff --git a/docs/memory.md b/docs/memory.md index 9da648cd6..c2f27dc3c 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -529,6 +529,10 @@ Terminal, inactive, and binding-superseded lifecycles issue the stop/cancel fence before revoking the issue grant. Raw bearer tokens are never persisted in manifests or diagnostics. With automatic capture enabled, terminal issues seen at daemon startup remain capture candidates instead of being assumed captured. +Subtree cleanup revokes a bearer only when its checkout generation matches the +cleanup target. If a newer generation for the same issue already holds the +live grant, old-generation cleanup leaves that bearer and its lifecycle state +unchanged. This grant applies to direct `memory.show` capsule reads as well as search, context, brief, related, docs, status, and code-intelligence tools. If the central service stops, the control-plane status is explicitly degraded and @@ -541,17 +545,28 @@ Completed parent roots remain retained across reconciliation and restart so a transient capture failure cannot erase the repository/run provenance needed for the next attempt. Their descendant leases remain active with the root so leaf cleanup cannot invalidate registered integration worktrees before OSYM-893 -records capture acknowledgement and performs ordered cleanup. +records capture acknowledgement and performs ordered cleanup. The run loop +records that acknowledgement only after the capture workflow completes for the +selected parent. Cleanup first receipts hook execution and detaches the parent +integration worktrees, then removes unleased descendants bottom-up and the +parent root last. Failure to persist the acknowledgement leaves the capture +candidate eligible for retry. For a parent, the capture path also reads validated durable controller state -and requires the matching lifecycle to be `completed`, its final attempt to be -passed for the same run and input version, its final evidence to be bound to the -same conversation, and its exact repository commit map to equal the runtime -envelope. A terminal run manifest or harness success by itself cannot authorize -parent capture. The durable binding explicitly marks a parent even when this -verified commit map is empty, so repository-neutral capture cannot inherit a -configured default leaf repository. That empty-target capture still records one -repository-neutral parent-runtime source reference with the durable run and -attempt identifiers. +and requires a matching terminal lifecycle. Successful completion additionally +requires its final attempt to be passed for the same run and input version, its +final evidence to be bound to the same conversation, and its exact repository +commit map to equal the runtime envelope. A terminal run manifest or harness +success by itself cannot authorize successful parent capture. Failed and +canceled controllers receive a repository-neutral parent binding with no +verified commit claims so their diagnostics can be captured before the explicit +retention policy runs. If restart proves that a parent launch never attached and +no `run.json` exists, the owned parent manifest plus the matching terminal +controller supplies only that neutral parent classification; it does not invent +run, attempt, command, or commit evidence. The durable binding explicitly marks +a parent even when its commit map is empty, so repository-neutral capture cannot +inherit a configured default leaf repository. A capture with an actual runtime +envelope records one repository-neutral parent-runtime source reference with +the durable run and attempt identifiers. Retained legacy run envelopes that lack usable run/attempt provenance are skipped as non-bindable entries during the pre-cleanup scan rather than preventing unrelated terminal captures from completing. diff --git a/docs/operations.md b/docs/operations.md index 14200ceb5..f6df97ad0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -24,6 +24,23 @@ hierarchy and lease artifact before reconciling terminal workspaces. The artifact is an internal durable record; operators should not edit it manually. Recovery rebuilds ancestor retention before cleanup, and cleanup remains blocked while any owner-identified lease for the checkout generation is active. +For a captured terminal parent, recovery also resumes its durable subtree +cleanup intent. The persisted order is parent worktree detachment, release of +that parent's lease owners, deepest-first descendant deletion, and parent-root +deletion. A higher-ancestor lease or an unexpired diagnostic hold keeps the +affected generation pending. Cleanup errors remain in the controller and are +retried on later ticks; operators should correct the reported hook, Git, or +filesystem condition rather than delete the path manually. A surviving +terminal descendant already named by an incomplete subtree-cleanup intent stays +under that intent during bootstrap; recovery does not reacquire a leaf lease or +route it through generic terminal cleanup. +Claimed, running, and retry-queued executions fence their exact workspace +generation. An unavailable OpenHands conversation store or a failed durable +lease/completion receipt also leaves cleanup pending for the next tick. +Cleanup refreshes the full tracker snapshot before retrying deletion, so a +newly reopened descendant is fenced before its workspace generation is +resolved. Missing generation-bound conversation evidence also blocks removal +unless a preparation-failed run proves that no conversation binding existed. ## 2. First-run flow @@ -527,6 +544,17 @@ downloaded fallback `cargo check-dev`, `cargo test-dev`, and `cargo clippy-dev` aliases. Treat that native dependency as part of the hosted deployment threat model before enabling memory in a multi-tenant service. Memory capture does not archive Linear issues. +When automatic capture completes a terminal parent capsule, `opensymphony run` +persists a capture acknowledgement before allowing subtree cleanup. A failed +capture is not acknowledged, so the parent evidence and protecting leases stay +available for the next capture attempt. If acknowledgement-state persistence +fails, the scheduler rolls back the in-memory cleanup intent so the next +automatic capture retries before any evidence is removed. Cleanup also remains +fenced while a parent repair provider intent lacks its terminal receipt. +After acknowledged parent cleanup removes the runtime root, the completed +cleanup intent remains the restart-safe automatic-capture marker. A malformed +conversation manifest on any generation-bound cleanup target blocks removal +until its archival evidence is repaired. Read commands such as `memory status`, `memory brief`, `memory related`, and `memory context` open the DuckDB index in read-only mode and do not run schema diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 0f55cac01..ea8bff5c3 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -549,8 +549,15 @@ root into a Git repository. It also covers parent `after_create` receipt/reuse/failure behavior and proves a child-controlled direct or worktree-conditional fsmonitor executable is rejected without running before the incomplete parent root is rolled back. Terminal cleanup also unregisters -the real integration worktrees and proves the same generation can be prepared -again without a stale Git registration. A lower-level regression proves the +the real integration worktrees while the parent and source generations still +exist, then removes the parent root and proves the same generation can be +prepared again without a stale Git registration. Scheduler regressions prove +capture acknowledgement, parent preparation before descendant removal, +deepest-first receipt recovery, hook-once behavior, exact-generation +tombstones, partial-directory deletion recovery, on-disk checkout generation +validation, completed-parent capture after restart, higher-owner preservation, +failed/canceled retention, and retry of visible cleanup failures. A lower-level +regression proves the orchestrator Git command overrides checkout-controlled fsmonitor configuration and default repository hooks at execution time. Focused OpenHands and Codex tests bind the same logical `parent_multi_checkout` envelope diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index e7c4e3555..09ef36c74 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -216,8 +216,77 @@ active returns through cleanup and baseline refresh. Operator and tracker terminal cancellation remains terminal. When a tracker terminal state arrives between repair worker turns, cancellation records no harness-interrupt intent or acknowledgement only after every attempt has conclusive stopped and cleanup -evidence. A conversation-bound indeterminate attempt remains retained for stop -reconciliation or operator recovery. +evidence and every provider operation has a terminal receipt. A +conversation-bound indeterminate attempt or provider intent without a receipt +remains retained for reconciliation or operator recovery. + +### Capture-acknowledged subtree cleanup + +A completed parent stays materialized until automatic terminal capture reports +that its workflow completed. The scheduler then persists the parent root and +every leased descendant as one hierarchy-generation cleanup intent. Failed and +canceled parents record the same acknowledgement. Failed parents remain +retained when the existing failed-workspace policy requests diagnostics +retention; canceled parents follow the existing cancellation cleanup policy. +Changing failed-workspace retention from enabled to disabled resumes a durable +retained cleanup intent on the next reconciliation tick. Enabling retention +while a failed cleanup is incomplete moves it back to retained before the +parent root is deleted. + +Removal has two ordered phases. First, the runtime backend applies its normal +conversation archival fence and the workspace manager receipts the parent +`before_remove` hook and removes each registered integration worktree with +`git worktree remove`. The parent root remains present during this phase. +Second, the scheduler releases only lease owners belonging to that parent and +removes descendants deepest first when no other active owner remains. The +parent root is removed last. Higher ancestors and bounded diagnostic holds +therefore preserve their checkout generations. +If a descendant is reactivated after its parent-owned leases are released, a +claimed, running, or retry-queued execution in that exact workspace generation +also fences deletion until the execution releases the workspace. +Before each pending cleanup retry, the scheduler refreshes the full tracker +snapshot. A newly active descendant fences deletion while its workspace +generation is unresolved or still matches the cleanup target; a resolved newer +generation does not keep the old target. +Lease-owner release and cleanup completion are persisted transactionally; a +failed state write restores the in-memory leases or incomplete cleanup state so +the next tick retries the durable receipt. + +Run-manifest receipts make the hook and each worktree removal idempotent. The +manager writes an attempted `before_remove` fence before launching the command; +if cleanup is interrupted before the result receipt, recovery preserves that +indeterminate record and does not repeat the best-effort side effect. A failed +`before_remove` receipt remains visible but does not block deletion. +Generation tombstones outside the deleted workspace are written before root +deletion and completed afterward; they also copy the successful hook receipt so +a partially removed metadata directory cannot cause the hook to run twice. The +external copy applies the same diagnostic redaction as the run manifest. On +restart, an incomplete tombstone proves that archival and cleanup preparation +already completed and resumes deletion even when part of the root remains or +its run manifest is gone. An already missing path is accepted only for an exact +issue, key, path, outcome, and generation match. Live checkout deletion reads +the generation and ownership from its on-disk manifests instead of trusting a +requested handle. Recovery enumerates both checkout roots and nested +`parents//` roots, so completed parent executions return as +capture candidates and their conversations pass through the archival fence. +The scheduler accepts only the root matching the durable controller generation; +superseded roots remain owned by their generation-specific cleanup state rather +than overwriting the current execution during restart. A tracker reopen waits +for pending prior-generation cleanup to complete before replacing the +controller and preparing a new parent root. Capture selects descendants from +the completed controller generation's active lease owner records rather than +the newer observed subtree, and an operator replan is rejected until that +cleanup finishes. Bootstrap recognizes surviving generations already named by +an incomplete cleanup intent and leaves them to that ordered phase instead of +reacquiring a leaf lease or applying generic terminal deletion. +Malformed conversation manifests fail closed for both checkout and parent +generation cleanup targets, and OpenHands generation cleanup requires the +conversation store for parent roots as well as checkouts, so the archival fence +cannot be bypassed. A missing generation-bound conversation manifest also +fails closed unless a preparation-failed run manifest proves that no harness +conversation binding was ever recorded. Hook, Git, manifest, tombstone, +permission, and filesystem failures remain visible on the durable cleanup +intent and retry on later scheduler ticks. The final-verification receipt selects an exact harness-observed foreground command. The trusted loader computes its SHA-256 identity from the transient @@ -273,8 +342,9 @@ controller authorizes terminal success only while its hierarchy generation still matches the current unblocked snapshot. COE-554 retains the completed parent root and its evidence-protecting leases across later reconciliation and daemon restart so automatic capture can retry without losing its runtime -envelope or source checkouts. OSYM-893 owns durable capture acknowledgement, -ordered lease release, cleanup intent, tombstones, and deletion. +envelope or source checkouts. The capture acknowledgement starts durable +bottom-up cleanup as described above; capture failure leaves the parent root +and leases unchanged for retry. A recovered parent worker must find the exact conversation manifest recorded by the controller. Its expected identity crosses the scheduler-to-worker request, and a missing or different manifest fails before any harness session is From e74662799f626a7899f45f3810e2e78ae3f36f0f Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sun, 13 Sep 2026 06:59:16 -0500 Subject: [PATCH 09/29] docs(memory): capture COE-556 and sync project docs Capture the merged subtree cleanup and recovery evidence and regenerate managed documentation from the complete 173-issue memory corpus. Record PR #239 and its merge source while preserving existing history. Validation: full sync-docs preview, memory lint --public-docs, and git diff --check passed; no pending docs or capture warnings. --- .opensymphony/memory/memory.yaml | 78 ++++++++++++++++++++++++++++++++ docs/accessibility.md | 2 + docs/architecture.md | 2 + docs/build.md | 2 + docs/cli.md | 2 + docs/code-intelligence.md | 2 + docs/code-review.md | 2 + docs/codex-runtime.md | 2 + docs/codex.md | 2 + docs/configuration.md | 2 + docs/control.md | 2 + docs/data-contracts.md | 4 +- docs/debugging.md | 2 + docs/desktop.md | 2 + docs/developer-experience.md | 2 + docs/docs.md | 2 + docs/documentation.md | 2 + docs/duckdb.md | 2 + docs/evidence.md | 4 +- docs/experiments.md | 3 ++ docs/frontend.md | 2 + docs/gateway.md | 2 + docs/graph-view.md | 2 + docs/harness-runtime.md | 2 + docs/harness.md | 3 ++ docs/installer.md | 2 + docs/linear.md | 2 + docs/mcp.md | 2 + docs/memory.md | 2 + docs/migration.md | 2 + docs/model-configuration.md | 2 + docs/okf.md | 2 + docs/openhands-runtime.md | 2 + docs/operations.md | 2 + docs/orchestrator.md | 2 + docs/outcomes.md | 3 ++ docs/planning.md | 2 + docs/process-protocol.md | 3 ++ docs/provenance.md | 3 ++ docs/provider.md | 3 ++ docs/quality.md | 4 +- docs/query-packs.md | 2 + docs/recovery.md | 37 +++++++++++++++ docs/release.md | 2 + docs/repository-layout.md | 2 + docs/review.md | 3 ++ docs/run-detail.md | 2 + docs/runner.md | 3 ++ docs/runtime.md | 2 + docs/rust.md | 2 + docs/scheduler.md | 3 ++ docs/security.md | 2 + docs/target-repo-bootstrap.md | 2 + docs/task-packets.md | 3 ++ docs/template.md | 2 + docs/testing-and-operations.md | 2 + docs/testing.md | 2 + docs/tests.md | 2 + docs/threejs.md | 2 + docs/tui.md | 2 + docs/ui.md | 2 + docs/verifiers.md | 3 ++ docs/web.md | 2 + docs/workflow.md | 2 + docs/workspace-lifecycle.md | 2 + docs/workspace.md | 3 ++ 66 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 docs/recovery.md diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index e31408eb1..d103eb237 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -60,6 +60,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -130,6 +131,7 @@ areas: - 'M12.6: Tree-sitter Code Intelligence' - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' + - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M6: Gateway And Stream Contract' - 'M7: Shared Client And Desktop Alpha' @@ -186,6 +188,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -277,6 +280,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -359,6 +363,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -431,6 +436,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -511,6 +517,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -616,6 +623,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -690,6 +698,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -779,6 +788,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -840,6 +850,7 @@ areas: - area:provenance - area:provider - area:quality + - area:recovery - area:review - area:runner - area:scheduler @@ -894,6 +905,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -988,6 +1000,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1078,6 +1091,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1205,6 +1219,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1286,6 +1301,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1366,6 +1382,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1436,6 +1453,7 @@ areas: - area:provenance - area:provider - area:quality + - area:recovery - area:review - area:runner - area:scheduler @@ -1463,6 +1481,7 @@ areas: - area:memory - area:orchestrator - area:provider + - area:recovery - area:review - area:scheduler - area:testing @@ -1512,6 +1531,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1591,6 +1611,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1673,6 +1694,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1716,6 +1738,7 @@ areas: - area:memory - area:orchestrator - area:provider + - area:recovery - area:review - area:testing - area:workspace-lifecycle @@ -1772,6 +1795,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -1938,6 +1962,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2026,6 +2051,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2130,6 +2156,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2224,6 +2251,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2310,6 +2338,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2401,6 +2430,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2489,6 +2519,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2598,6 +2629,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2680,6 +2712,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2775,6 +2808,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2829,6 +2863,7 @@ areas: - area:orchestrator - area:outcomes - area:provider + - area:recovery - area:review - area:testing - area:verifiers @@ -2878,6 +2913,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -2940,6 +2976,7 @@ areas: - area:orchestrator - area:process-protocol - area:provider + - area:recovery - area:review - area:runner - area:testing @@ -2962,6 +2999,7 @@ areas: - area:orchestrator - area:provenance - area:provider + - area:recovery - area:review - area:task-packets - area:testing @@ -2979,7 +3017,9 @@ areas: - provider source_refs: linear_labels: + - area:orchestrator - area:provider + - area:recovery - area:review - area:workspace-lifecycle linear_milestones: @@ -3004,6 +3044,7 @@ areas: - area:provenance - area:provider - area:quality + - area:recovery - area:review - area:runner - area:scheduler @@ -3059,6 +3100,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3088,6 +3130,20 @@ areas: - 'M12.96: Parent Integration Lifecycle' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' + recovery: + title: Recovery + docs_target: docs/recovery.md + visibility: public + status: stable + confidence: 95 + aliases: + - recovery + source_refs: + linear_labels: + - area:recovery + - area:workspace-lifecycle + linear_milestones: + - 'M12.96: Parent Integration Lifecycle' release: title: Release docs_target: docs/release.md @@ -3130,6 +3186,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3189,6 +3246,8 @@ areas: - review source_refs: linear_labels: + - area:orchestrator + - area:recovery - area:review - area:workspace-lifecycle linear_milestones: @@ -3236,6 +3295,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3279,6 +3339,7 @@ areas: - area:memory - area:orchestrator - area:provider + - area:recovery - area:review - area:runner - area:testing @@ -3336,6 +3397,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3417,6 +3479,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3461,6 +3524,7 @@ areas: - area:memory - area:orchestrator - area:provider + - area:recovery - area:review - area:scheduler - area:testing @@ -3518,6 +3582,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3634,6 +3699,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3678,6 +3744,7 @@ areas: - area:memory - area:orchestrator - area:provider + - area:recovery - area:review - area:task-packets - area:testing @@ -3728,6 +3795,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3807,6 +3875,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3920,6 +3989,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -3992,6 +4062,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -4071,6 +4142,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -4159,6 +4231,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -4226,6 +4299,7 @@ areas: - area:memory - area:orchestrator - area:provider + - area:recovery - area:review - area:testing - area:verifiers @@ -4275,6 +4349,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -4361,6 +4436,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail @@ -4405,6 +4481,7 @@ areas: - area:memory - area:orchestrator - area:provider + - area:recovery - area:review - area:testing - area:workspace @@ -4469,6 +4546,7 @@ areas: - area:provider - area:quality - area:query-packs + - area:recovery - area:release - area:review - area:run-detail diff --git a/docs/accessibility.md b/docs/accessibility.md index edc616616..8fa0585b6 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/architecture.md b/docs/architecture.md index 50246fed4..538c51b6a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -630,6 +630,7 @@ Notable removals: - COE-549: Verified Checkouts Instructions And Harness Envelopes - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays +- COE-556: Bottom-Up Subtree Cleanup And Recovery ## Source refs @@ -765,5 +766,6 @@ Notable removals: - COE-549 - COE-550 - COE-551 +- COE-556 diff --git a/docs/build.md b/docs/build.md index 61bd541fa..599711588 100644 --- a/docs/build.md +++ b/docs/build.md @@ -123,6 +123,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -222,6 +223,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/cli.md b/docs/cli.md index 0d894d48b..466f686e4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -120,6 +120,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -216,6 +217,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index d4b1e8fad..ae3e2df4e 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -276,6 +276,7 @@ references, and never returns source snippets. - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -334,6 +335,7 @@ references, and never returns source snippets. - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/code-review.md b/docs/code-review.md index 270a6896f..ddbf5dda8 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index 6e1904828..a7ba7546c 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/codex.md b/docs/codex.md index 27566f274..59ebb7a85 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/configuration.md b/docs/configuration.md index 6ca4b46f9..57aa9f8ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1097,6 +1097,7 @@ copy that guide into the target repository. - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1253,6 +1254,7 @@ copy that guide into the target repository. - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/control.md b/docs/control.md index 7f0d26dd5..af93daba5 100644 --- a/docs/control.md +++ b/docs/control.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index afdcc88d4..12e8ebb10 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -14,9 +14,9 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. -- COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -36,6 +36,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -48,6 +49,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/debugging.md b/docs/debugging.md index 2c56e2c2d..1bfd910da 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/desktop.md b/docs/desktop.md index e33e9ba8a..8697522c8 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -123,6 +123,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -224,6 +225,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index d2804cec3..54b498502 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/docs.md b/docs/docs.md index e16b958d6..1347161c1 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -123,6 +123,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -223,6 +224,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/documentation.md b/docs/documentation.md index 310f7cd06..5b3655aba 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -84,6 +84,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -144,6 +145,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/duckdb.md b/docs/duckdb.md index 74c642f10..98fd081ae 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/evidence.md b/docs/evidence.md index ebd2d36b1..68d9db66b 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -14,9 +14,9 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. -- COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -36,6 +36,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -48,6 +49,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/experiments.md b/docs/experiments.md index 20c9d7d96..ec9f95d54 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-566: Implement configurable run matrices and scheduling ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-566 diff --git a/docs/frontend.md b/docs/frontend.md index 9f6b5a927..422190b03 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -144,6 +145,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/gateway.md b/docs/gateway.md index 76eef150c..ed76e11db 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -144,6 +144,7 @@ baseline, not an issue-workspace overlay. - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -244,6 +245,7 @@ baseline, not an issue-workspace overlay. - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/graph-view.md b/docs/graph-view.md index 47a8c0d64..4f5713303 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index c75e1d950..1569e031f 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/harness.md b/docs/harness.md index 46d5d460b..074e7f2a1 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) ## Important invariants @@ -33,11 +34,13 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery ## Source refs - COE-553 - COE-554 - COE-555 +- COE-556 diff --git a/docs/installer.md b/docs/installer.md index 5b0aed0b0..f8b3831e9 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/linear.md b/docs/linear.md index d798559e1..405153a1b 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -123,6 +123,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -222,6 +223,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/mcp.md b/docs/mcp.md index 11e24d833..956cefa3f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/memory.md b/docs/memory.md index c2f27dc3c..47021b860 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -832,6 +832,7 @@ are covered by the same archive operation. - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -996,6 +997,7 @@ are covered by the same archive operation. - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/migration.md b/docs/migration.md index 8b73b7d9c..31ece3a02 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index 490aeae3b..2698b6145 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/okf.md b/docs/okf.md index 81c42d357..046775ec5 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index 4b4b4e846..48b0a8e70 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -83,6 +83,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -142,6 +143,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/operations.md b/docs/operations.md index f6df97ad0..3eb2af5d1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1064,6 +1064,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -1231,6 +1232,7 @@ repo-local GraphQL helper assets copied by `opensymphony init`. - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index 5de19d613..bcacc4fb6 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/outcomes.md b/docs/outcomes.md index bfd9fbd90..4ae0c72d4 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-564: Implement verifier execution and outcome records ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-564 diff --git a/docs/planning.md b/docs/planning.md index 5288351a1..6e877647a 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -81,6 +81,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -138,6 +139,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index c6c23506a..69ea792c4 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-567 diff --git a/docs/provenance.md b/docs/provenance.md index 2ec61fb38..44edd88f6 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-563 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-563: Implement task-packet admission and freeze tooling ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-563 diff --git a/docs/provider.md b/docs/provider.md index 275de5a0d..2a1abab94 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 ## Current model - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) ## Important invariants @@ -29,9 +30,11 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 ## Recent changes - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery ## Source refs - COE-555 +- COE-556 diff --git a/docs/quality.md b/docs/quality.md index ca1edead3..7efefbca9 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -14,9 +14,9 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-562 contributed: No merged PR source was matched during capture. - COE-563 contributed: No merged PR source was matched during capture. -- COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -36,6 +36,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -48,6 +49,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/query-packs.md b/docs/query-packs.md index 7096bb913..1d5918406 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 000000000..e9a2f475d --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,37 @@ +--- +type: topic-doc +area: recovery +visibility: public +last_memory_sync: 2026-09-13T11:59:04.642113+00:00 +--- + +# Recovery + + + +## Current model + +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) + +## Important invariants + +- Preserve the behavior described in the recent captured changes unless current code and tests show it has changed. +- Use capsule source refs to inspect the original PR or Linear issue when context is ambiguous. + +## Operational flow + +- No generated diagram requested for this sync. + +## Known gotchas + +- No area-specific gotchas were inferred from the selected memory. + +## Recent changes + +- COE-556: Bottom-Up Subtree Cleanup And Recovery + +## Source refs + +- COE-556 + + diff --git a/docs/release.md b/docs/release.md index e59960c4d..a60d7b555 100644 --- a/docs/release.md +++ b/docs/release.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/repository-layout.md b/docs/repository-layout.md index b7ff73a21..1915a58d5 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -249,6 +249,7 @@ of dead bridge code. - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery ## Source refs @@ -313,5 +314,6 @@ of dead bridge code. - COE-553 - COE-554 - COE-555 +- COE-556 diff --git a/docs/review.md b/docs/review.md index 71454b788..cbfaaa732 100644 --- a/docs/review.md +++ b/docs/review.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 ## Current model - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) ## Important invariants @@ -29,9 +30,11 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 ## Recent changes - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery ## Source refs - COE-555 +- COE-556 diff --git a/docs/run-detail.md b/docs/run-detail.md index 5fc3c42a4..a71321413 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -80,6 +80,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -136,6 +137,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/runner.md b/docs/runner.md index e3392572a..d7dd27105 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-567 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-567: Implement run lifecycle and process-protocol primitives ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-567 diff --git a/docs/runtime.md b/docs/runtime.md index 5e09a1544..61593ec96 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -122,6 +122,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/rust.md b/docs/rust.md index 931634125..3f9a15802 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -84,6 +84,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -144,6 +145,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/scheduler.md b/docs/scheduler.md index a970f85d8..098fb66a1 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-566 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-566: Implement configurable run matrices and scheduling ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-566 diff --git a/docs/security.md b/docs/security.md index a67b19928..1c5aa5d8e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -123,6 +123,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -222,6 +223,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index 2474f8f3e..178f41781 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/task-packets.md b/docs/task-packets.md index 2a55650de..9c995282c 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-563 contributed: No merged PR source was matched during capture. - COE-565 contributed: No merged PR source was matched during capture. @@ -35,6 +36,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-563: Implement task-packet admission and freeze tooling - COE-565: Implement isolated workspace materialization @@ -43,6 +45,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-563 - COE-565 diff --git a/docs/template.md b/docs/template.md index 10540be6e..ef8b315eb 100644 --- a/docs/template.md +++ b/docs/template.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index ea8bff5c3..50ac7c1ee 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -797,6 +797,7 @@ That document covers: - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery ## Source refs @@ -948,5 +949,6 @@ That document covers: - COE-551 - COE-554 - COE-555 +- COE-556 diff --git a/docs/testing.md b/docs/testing.md index 728852412..281c009bb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -121,6 +121,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -220,6 +221,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/tests.md b/docs/tests.md index a50881f21..cf1867b8f 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -84,6 +84,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -144,6 +145,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/threejs.md b/docs/threejs.md index 086029f65..45aaf1127 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -82,6 +82,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -140,6 +141,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/tui.md b/docs/tui.md index 455ebab8e..9d7021226 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -212,6 +213,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/ui.md b/docs/ui.md index 8b5292b06..78b40f6b7 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -118,6 +118,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -214,6 +215,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/verifiers.md b/docs/verifiers.md index f5f2ff36f..4959027cb 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-564 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-564: Implement verifier execution and outcome records ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-564 diff --git a/docs/web.md b/docs/web.md index 4a1505bd1..80282f1ee 100644 --- a/docs/web.md +++ b/docs/web.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/workflow.md b/docs/workflow.md index cfd8516a4..04a0c22af 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -84,6 +84,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -144,6 +145,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index 0be2f8a6b..dd42f7836 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -79,6 +79,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-562: Implement artifact validation and digest primitives - COE-563: Implement task-packet admission and freeze tooling - COE-564: Implement verifier execution and outcome records @@ -134,6 +135,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-562 - COE-563 - COE-564 diff --git a/docs/workspace.md b/docs/workspace.md index 2223f7ffc..abf276886 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-553 contributed: PR #236: Add parent execution roots and shared child worktrees (merge `9abff5a`) - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) +- COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-565 contributed: No merged PR source was matched during capture. ## Important invariants @@ -34,6 +35,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-553: Parent Execution Roots And Child Workspace Reuse - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle +- COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-565: Implement isolated workspace materialization ## Source refs @@ -41,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-553 - COE-554 - COE-555 +- COE-556 - COE-565 From ea8034d382909bebb7eceb029f81660988a21b08 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Tue, 22 Sep 2026 00:06:53 -0500 Subject: [PATCH 10/29] feat: expose multi-repository operator state (#240) * feat(control-plane): project multi-repository operator state * fix(control-plane): harden operator projections * fix(gateway): keep remote run details path-safe --- crates/opensymphony-cli/src/lib.rs | 3 + .../src/orchestrator_run/snapshot.rs | 58 +++++++++ crates/opensymphony-cli/tests/tui.rs | 1 + crates/opensymphony-control/src/lib.rs | 1 + .../tests/control_plane.rs | 1 + .../opensymphony-domain/src/control_plane.rs | 106 +++++++++++++++ crates/opensymphony-domain/src/lib.rs | 14 +- .../tests/snapshot_serialization.rs | 45 +++++++ crates/opensymphony-gateway-schema/src/run.rs | 105 +++++++++++++++ .../tests/gateway_schema.rs | 1 + crates/opensymphony-gateway/src/lib.rs | 122 +++++++++++++++++- crates/opensymphony-gateway/tests/gateway.rs | 59 ++++++++- crates/opensymphony-tui/src/lib.rs | 76 +++++++++++ crates/opensymphony-tui/tests/reducer.rs | 2 + docs/architecture.md | 5 + docs/configuration.md | 4 + docs/memory.md | 4 + docs/openhands-agent-server.md | 4 + docs/operations.md | 5 + docs/testing-and-operations.md | 4 + docs/workspace-and-lifecycle.md | 4 + .../gateway-schema/__tests__/fixtures.test.ts | 5 +- .../__tests__/fixtures/run_detail.json | 23 +++- packages/gateway-schema/src/index.ts | 10 ++ packages/gateway-schema/src/run.ts | 86 ++++++++++++ packages/ui-core/src/app-shell.ts | 19 +++ tests/action_handler.rs | 1 + tests/task_graph_mutations_routes.rs | 1 + 28 files changed, 747 insertions(+), 22 deletions(-) diff --git a/crates/opensymphony-cli/src/lib.rs b/crates/opensymphony-cli/src/lib.rs index 7fce13e60..ef89c7006 100644 --- a/crates/opensymphony-cli/src/lib.rs +++ b/crates/opensymphony-cli/src/lib.rs @@ -2105,6 +2105,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, }, IssueSnapshot { identifier: "OSYM-401".to_owned(), @@ -2152,6 +2153,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, }, IssueSnapshot { identifier: "OSYM-402".to_owned(), @@ -2207,6 +2209,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, }, ]; let running_issues = issues diff --git a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs index cb407b847..803fc5c62 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs @@ -182,6 +182,63 @@ fn map_issue( .as_ref() .is_some_and(|binding| binding.resolved_binding().is_none()); let hierarchy_blocked = hierarchy.and_then(|state| state.blocked_reason.clone()); + let operator = Some(crate::opensymphony_domain::ControlPlaneOperatorSnapshot { + routing_mode: None, + active_project_set: Vec::new(), + linear_project: issue + .issue + .project_slug + .clone() + .or_else(|| issue.issue.project_id.clone()), + binding_status: repository_binding.as_ref().map(|binding| match binding { + crate::opensymphony_domain::RepositoryBindingOutcome::Resolved(_) => "resolved", + crate::opensymphony_domain::RepositoryBindingOutcome::MissingBinding => "missing_binding", + crate::opensymphony_domain::RepositoryBindingOutcome::UnknownAlias(_) => "unknown_alias", + crate::opensymphony_domain::RepositoryBindingOutcome::MultipleBindings(_) => "multiple_bindings", + crate::opensymphony_domain::RepositoryBindingOutcome::RepositoryNotAllowedForProject(_, _) => "repository_not_allowed_for_project", + crate::opensymphony_domain::RepositoryBindingOutcome::ParentBindingNotAllowed => "parent_binding_not_allowed", + crate::opensymphony_domain::RepositoryBindingOutcome::ProjectOutsideActiveSet(_) => "project_outside_active_set", + }.to_owned()), + parent: hierarchy.map( + |state| crate::opensymphony_domain::ControlPlaneParentSnapshot { + parent_id: issue.issue.identifier.to_string(), + state: None, + hierarchy_generation: Some(state.generation), + blocked_reason: hierarchy_blocked.clone(), + descendant_repositories: Vec::new(), + checkout_handles: Vec::new(), + }, + ), + repository: repository_binding.as_ref().and_then(|binding| { + binding.resolved_binding().map(|binding| { + crate::opensymphony_domain::ControlPlaneRepositorySnapshot { + canonical_id: binding.repository.id.as_str().to_owned(), + display_alias: binding.alias.clone(), + safe_remote_fingerprint: Some( + binding + .repository + .safe_remote_fingerprint + .as_str() + .to_owned(), + ), + config_generation: Some(binding.config_generation.clone()), + inventory_generation: Some(binding.inventory_generation.clone()), + checkout_generation: None, + target_branch: None, + target_commit: None, + instruction_source: None, + instruction_hash: None, + } + }) + }), + leases: Vec::new(), + repairs: Vec::new(), + memory: None, + containment: None, + provider: None, + verification: None, + cleanup: None, + }); IssueSnapshot { identifier: issue.issue.identifier.to_string(), @@ -363,6 +420,7 @@ fn map_issue( Some(HarnessInterruptStatus::TimedOut) ), cancel_reason: interrupt.map(|interrupt| interrupt.command.reason.as_str().to_string()), + operator, } } diff --git a/crates/opensymphony-cli/tests/tui.rs b/crates/opensymphony-cli/tests/tui.rs index 8bf5e349f..d1cf13479 100644 --- a/crates/opensymphony-cli/tests/tui.rs +++ b/crates/opensymphony-cli/tests/tui.rs @@ -104,6 +104,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }], recent_events: vec![RecentEvent { diff --git a/crates/opensymphony-control/src/lib.rs b/crates/opensymphony-control/src/lib.rs index dcb04cf29..a09ced9cb 100644 --- a/crates/opensymphony-control/src/lib.rs +++ b/crates/opensymphony-control/src/lib.rs @@ -482,6 +482,7 @@ mod tests { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }], recent_events: vec![RecentEvent { diff --git a/crates/opensymphony-control/tests/control_plane.rs b/crates/opensymphony-control/tests/control_plane.rs index 433fc5b21..270d919ee 100644 --- a/crates/opensymphony-control/tests/control_plane.rs +++ b/crates/opensymphony-control/tests/control_plane.rs @@ -89,6 +89,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }], recent_events: vec![RecentEvent { diff --git a/crates/opensymphony-domain/src/control_plane.rs b/crates/opensymphony-domain/src/control_plane.rs index 5bb51c1ed..95c9fa760 100644 --- a/crates/opensymphony-domain/src/control_plane.rs +++ b/crates/opensymphony-domain/src/control_plane.rs @@ -178,6 +178,112 @@ pub struct ControlPlaneIssueSnapshot { pub cancel_timed_out: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub cancel_reason: Option, + /// Sanitized multi-repository and lifecycle facts. Missing means the + /// authoritative runtime has not provided that fact; clients must not + /// infer completion or containment from its absence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operator: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneOperatorSnapshot { + pub routing_mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub active_project_set: Vec, + pub linear_project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding_status: Option, + pub parent: Option, + pub repository: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub leases: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repairs: Vec, + pub memory: Option, + pub containment: Option, + pub provider: Option, + pub verification: Option, + pub cleanup: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneRepositorySnapshot { + pub canonical_id: String, + pub display_alias: String, + pub safe_remote_fingerprint: Option, + pub config_generation: Option, + pub inventory_generation: Option, + pub checkout_generation: Option, + pub target_branch: Option, + pub target_commit: Option, + pub instruction_source: Option, + pub instruction_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneParentSnapshot { + pub parent_id: String, + pub state: Option, + pub hierarchy_generation: Option, + pub blocked_reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub descendant_repositories: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checkout_handles: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneLeaseSnapshot { + pub owner_id: String, + pub owner_kind: String, + pub repository_id: String, + pub checkout_generation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneRepairSnapshot { + pub id: String, + pub repository_id: Option, + pub status: String, + pub pull_request_url: Option, + pub target_commit: Option, + pub instruction_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneMemorySnapshot { + pub scope: String, + pub source_freshness: Option, + pub degraded: bool, + pub overlay_provenance: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneContainmentSnapshot { + pub requested_scope: Option, + pub effective_containment: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneProviderSnapshot { + pub provider: String, + pub step: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneVerificationSnapshot { + pub attempts: u32, + pub status: String, + pub final_evidence: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControlPlaneCleanupSnapshot { + pub status: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blockers: Vec, + pub retry_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/opensymphony-domain/src/lib.rs b/crates/opensymphony-domain/src/lib.rs index 6c76a8e40..17a99f576 100644 --- a/crates/opensymphony-domain/src/lib.rs +++ b/crates/opensymphony-domain/src/lib.rs @@ -15,11 +15,15 @@ mod tracker; pub const CRATE_NAME: &str = "opensymphony-domain"; pub use control_plane::{ - ControlPlaneAgentServerStatus, ControlPlaneConversationEvent, ControlPlaneDaemonSnapshot, - ControlPlaneDaemonState, ControlPlaneDaemonStatus, ControlPlaneFileChange, - ControlPlaneFileChangeKind, ControlPlaneIssueRuntimeState, ControlPlaneIssueSnapshot, - ControlPlaneMemoryServerStatus, ControlPlaneMetricsSnapshot, ControlPlaneRecentEvent, - ControlPlaneRecentEventKind, ControlPlaneWorkerOutcome, SnapshotEnvelope, + ControlPlaneAgentServerStatus, ControlPlaneCleanupSnapshot, ControlPlaneContainmentSnapshot, + ControlPlaneConversationEvent, ControlPlaneDaemonSnapshot, ControlPlaneDaemonState, + ControlPlaneDaemonStatus, ControlPlaneFileChange, ControlPlaneFileChangeKind, + ControlPlaneIssueRuntimeState, ControlPlaneIssueSnapshot, ControlPlaneLeaseSnapshot, + ControlPlaneMemoryServerStatus, ControlPlaneMemorySnapshot, ControlPlaneMetricsSnapshot, + ControlPlaneOperatorSnapshot, ControlPlaneParentSnapshot, ControlPlaneProviderSnapshot, + ControlPlaneRecentEvent, ControlPlaneRecentEventKind, ControlPlaneRepairSnapshot, + ControlPlaneRepositorySnapshot, ControlPlaneVerificationSnapshot, ControlPlaneWorkerOutcome, + SnapshotEnvelope, }; pub use harness::HarnessAdapter; pub use identifiers::{ diff --git a/crates/opensymphony-domain/tests/snapshot_serialization.rs b/crates/opensymphony-domain/tests/snapshot_serialization.rs index 59d8cb682..7aaad6d87 100644 --- a/crates/opensymphony-domain/tests/snapshot_serialization.rs +++ b/crates/opensymphony-domain/tests/snapshot_serialization.rs @@ -86,6 +86,7 @@ fn fixture() -> SnapshotEnvelope { cancel_timed_out: false, cancel_reason: None, detached: false, + operator: None, }], recent_events: vec![RecentEvent { happened_at: now, @@ -162,3 +163,47 @@ fn older_issue_snapshots_without_project_metadata_still_decode() { assert_eq!(issue.workspace_label, None); assert_eq!(issue.blocked_by, Vec::::new()); } + +#[test] +fn operator_projection_round_trips_without_secret_or_path_fields() { + use crate::opensymphony_domain::{ + ControlPlaneContainmentSnapshot, ControlPlaneOperatorSnapshot, + ControlPlaneRepositorySnapshot, + }; + + let projection = ControlPlaneOperatorSnapshot { + routing_mode: Some("project_set".to_owned()), + active_project_set: vec!["project-a".to_owned()], + linear_project: Some("project-a".to_owned()), + binding_status: Some("resolved".to_owned()), + parent: None, + repository: Some(ControlPlaneRepositorySnapshot { + canonical_id: "github:repository:123".to_owned(), + display_alias: "backend".to_owned(), + safe_remote_fingerprint: Some("sha256:fingerprint".to_owned()), + config_generation: Some("config-1".to_owned()), + inventory_generation: Some("inventory-1".to_owned()), + checkout_generation: None, + target_branch: Some("develop".to_owned()), + target_commit: Some("abc123".to_owned()), + instruction_source: Some("AGENTS.md".to_owned()), + instruction_hash: Some("sha256:instructions".to_owned()), + }), + leases: Vec::new(), + repairs: Vec::new(), + memory: None, + containment: Some(ControlPlaneContainmentSnapshot { + requested_scope: Some("trusted_host".to_owned()), + effective_containment: "trusted_host".to_owned(), + }), + provider: None, + verification: None, + cleanup: None, + }; + let encoded = serde_json::to_value(&projection).expect("serialize operator projection"); + assert!(encoded.get("workspace_path").is_none()); + assert!(encoded.get("remote_url").is_none()); + let decoded: ControlPlaneOperatorSnapshot = + serde_json::from_value(encoded).expect("deserialize operator projection"); + assert_eq!(decoded, projection); +} diff --git a/crates/opensymphony-gateway-schema/src/run.rs b/crates/opensymphony-gateway-schema/src/run.rs index e5001e7a2..8be4e88a6 100644 --- a/crates/opensymphony-gateway-schema/src/run.rs +++ b/crates/opensymphony-gateway-schema/src/run.rs @@ -107,6 +107,111 @@ pub struct RunDetail { pub cancel_timed_out: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub cancel_reason: Option, + /// Sanitized repository and parent lifecycle facts. Absent facts remain + /// absent instead of being inferred by clients. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operator: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunOperatorSnapshot { + pub routing_mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub active_project_set: Vec, + pub linear_project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding_status: Option, + pub parent: Option, + pub repository: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub leases: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repairs: Vec, + pub memory: Option, + pub containment: Option, + pub provider: Option, + pub verification: Option, + pub cleanup: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunRepositorySnapshot { + pub canonical_id: String, + pub display_alias: String, + pub safe_remote_fingerprint: Option, + pub config_generation: Option, + pub inventory_generation: Option, + pub checkout_generation: Option, + pub target_branch: Option, + pub target_commit: Option, + pub instruction_source: Option, + pub instruction_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunParentSnapshot { + pub parent_id: String, + pub state: Option, + pub hierarchy_generation: Option, + pub blocked_reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub descendant_repositories: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checkout_handles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunLeaseSnapshot { + pub owner_id: String, + pub owner_kind: String, + pub repository_id: String, + pub checkout_generation: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunRepairSnapshot { + pub id: String, + pub repository_id: Option, + pub status: String, + pub pull_request_url: Option, + pub target_commit: Option, + pub instruction_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunMemorySnapshot { + pub scope: String, + pub source_freshness: Option, + pub degraded: bool, + pub overlay_provenance: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunContainmentSnapshot { + pub requested_scope: Option, + pub effective_containment: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunProviderSnapshot { + pub provider: String, + pub step: String, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunVerificationSnapshot { + pub attempts: u32, + pub status: String, + pub final_evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunCleanupSnapshot { + pub status: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blockers: Vec, + pub retry_count: u32, } /// Action a client may dispatch on a run. diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 40b7c403b..f8e8e5738 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -585,6 +585,7 @@ fn run_detail_roundtrips() { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, }; let json = must_serialize(&run); let back: RunDetail = must_deserialize(&json); diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index 0b6cc5aa5..bb9985484 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -109,9 +109,11 @@ pub use crate::opensymphony_gateway_schema::{ }, run::{ ChangedFileEntry, DiffHunk, DiffLine, FileChangeKind, FileDiffPage, ReleaseReason, - RunAction, RunDetail, RunDiagnostics, RunEvent, RunEventPage, RunFilesPage, - RunLifecycleState, RunLivenessEnvelope, RunPhase, RunProgress, RunStatus, - RunStreamLiveness, SafeActions, + RunAction, RunCleanupSnapshot, RunContainmentSnapshot, RunDetail, RunDiagnostics, RunEvent, + RunEventPage, RunFilesPage, RunLeaseSnapshot, RunLifecycleState, RunLivenessEnvelope, + RunMemorySnapshot, RunOperatorSnapshot, RunParentSnapshot, RunPhase, RunProgress, + RunProviderSnapshot, RunRepairSnapshot, RunRepositorySnapshot, RunStatus, + RunStreamLiveness, RunVerificationSnapshot, SafeActions, }, snapshot::{ DashboardSnapshot, GatewayHealth, GatewayMetrics, ProjectDetail, ProjectIssueSummary, @@ -4652,6 +4654,7 @@ async fn get_run_detail( cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, }), ); } @@ -4673,7 +4676,7 @@ async fn get_run_detail( let terminal_tracker_state = state .terminal_states .contains(&issue.tracker_state.trim().to_ascii_lowercase()); - let (status, lifecycle_state) = match issue.runtime_state { + let (status, mut lifecycle_state) = match issue.runtime_state { ControlPlaneIssueRuntimeState::Idle if !dispatchable => { (RunStatus::Unclaimed, RunLifecycleState::Backlog) } @@ -4697,6 +4700,17 @@ async fn get_run_detail( } ControlPlaneIssueRuntimeState::Failed => (RunStatus::Released, RunLifecycleState::Failed), }; + if matches!( + issue + .operator + .as_ref() + .and_then(|operator| operator.cleanup.as_ref()) + .map(|cleanup| cleanup.status.as_str()), + Some("pending" | "removing" | "retained") + ) && lifecycle_state == RunLifecycleState::Completed + { + lifecycle_state = RunLifecycleState::Releasing; + } // Runtime completion describes the worker execution, not the tracker // category. A successful worker can be released as TrackerInactive while @@ -4774,9 +4788,11 @@ async fn get_run_detail( .then(|| format!("conv-{}", issue.conversation_id_suffix)), workspace_id: (!issue.workspace_path_suffix.is_empty()) .then(|| issue.workspace_path_suffix.clone()), - workspace_path: workspace_path - .as_ref() - .map(|path| path.display().to_string()), + // The gateway has no per-request trusted-local authorization + // boundary, so exact host paths never cross this remote DTO. + // Local actions resolve the contained path server-side from the + // sanitized workspace suffix. + workspace_path: None, branch_name: issue.branch_name.clone(), pr_url, // A Codex run carries a Codex thread id; report the harness so the @@ -4818,10 +4834,101 @@ async fn get_run_detail( cancel_failed: issue.cancel_failed, cancel_timed_out: issue.cancel_timed_out, cancel_reason: issue.cancel_reason.clone(), + operator: issue.operator.as_ref().map(map_operator_snapshot), }), ) } +fn map_operator_snapshot( + operator: &crate::opensymphony_domain::ControlPlaneOperatorSnapshot, +) -> RunOperatorSnapshot { + RunOperatorSnapshot { + routing_mode: operator.routing_mode.clone(), + active_project_set: operator.active_project_set.clone(), + linear_project: operator.linear_project.clone(), + binding_status: operator.binding_status.clone(), + parent: operator.parent.as_ref().map(|parent| RunParentSnapshot { + parent_id: parent.parent_id.clone(), + state: parent.state.clone(), + hierarchy_generation: parent.hierarchy_generation, + blocked_reason: parent.blocked_reason.clone(), + descendant_repositories: parent.descendant_repositories.clone(), + checkout_handles: parent.checkout_handles.clone(), + }), + repository: operator + .repository + .as_ref() + .map(|repository| RunRepositorySnapshot { + canonical_id: repository.canonical_id.clone(), + display_alias: repository.display_alias.clone(), + safe_remote_fingerprint: repository.safe_remote_fingerprint.clone(), + config_generation: repository.config_generation.clone(), + inventory_generation: repository.inventory_generation.clone(), + checkout_generation: repository.checkout_generation.clone(), + target_branch: repository.target_branch.clone(), + target_commit: repository.target_commit.clone(), + instruction_source: repository.instruction_source.clone(), + instruction_hash: repository.instruction_hash.clone(), + }), + leases: operator + .leases + .iter() + .map(|lease| RunLeaseSnapshot { + owner_id: lease.owner_id.clone(), + owner_kind: lease.owner_kind.clone(), + repository_id: lease.repository_id.clone(), + checkout_generation: lease.checkout_generation.clone(), + }) + .collect(), + repairs: operator + .repairs + .iter() + .map(|repair| RunRepairSnapshot { + id: repair.id.clone(), + repository_id: repair.repository_id.clone(), + status: repair.status.clone(), + pull_request_url: repair.pull_request_url.clone(), + target_commit: repair.target_commit.clone(), + instruction_hash: repair.instruction_hash.clone(), + }) + .collect(), + memory: operator.memory.as_ref().map(|memory| RunMemorySnapshot { + scope: memory.scope.clone(), + source_freshness: memory.source_freshness.clone(), + degraded: memory.degraded, + overlay_provenance: memory.overlay_provenance.clone(), + }), + containment: operator + .containment + .as_ref() + .map(|containment| RunContainmentSnapshot { + requested_scope: containment.requested_scope.clone(), + effective_containment: containment.effective_containment.clone(), + }), + provider: operator + .provider + .as_ref() + .map(|provider| RunProviderSnapshot { + provider: provider.provider.clone(), + step: provider.step.clone(), + status: provider.status.clone(), + }), + verification: operator + .verification + .as_ref() + .map(|verification| RunVerificationSnapshot { + attempts: verification.attempts, + status: verification.status.clone(), + final_evidence: verification.final_evidence.clone(), + }), + cleanup: operator.cleanup.as_ref().map(|cleanup| RunCleanupSnapshot { + status: cleanup.status.clone(), + blockers: cleanup.blockers.clone(), + retry_count: cleanup.retry_count, + }), + } +} + #[derive(Debug, serde::Deserialize)] struct RunEventQuery { page_token: Option, @@ -6435,6 +6542,7 @@ exit 2 cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, } } diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index 2fc28e5e6..870160e9a 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -759,6 +759,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }], recent_events: vec![RecentEvent { @@ -854,6 +855,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }, // Completed issue with events and modified files @@ -953,6 +955,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }, // Failed issue, first attempt (no retries exhausted) @@ -1001,6 +1004,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }, // RetryQueued issue: queued but NOT eligible (not idle) @@ -1049,6 +1053,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }, // Blocked Idle issue: NOT eligible AND NOT queued @@ -1097,6 +1102,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }, ], @@ -4595,7 +4601,38 @@ async fn gateway_task_graph_empty_project_without_linear_returns_empty_ok() { #[tokio::test] async fn gateway_serves_run_detail() { - let store = SnapshotStore::new(fixture_snapshot(0)); + let mut snapshot = fixture_snapshot(0); + snapshot.issues[0].operator = Some( + opensymphony::opensymphony_domain::ControlPlaneOperatorSnapshot { + routing_mode: Some("project_set".to_owned()), + active_project_set: vec!["project-a".to_owned()], + linear_project: Some("project-a".to_owned()), + binding_status: Some("resolved".to_owned()), + parent: None, + repository: Some( + opensymphony::opensymphony_domain::ControlPlaneRepositorySnapshot { + canonical_id: "github:repository:255".to_owned(), + display_alias: "backend".to_owned(), + safe_remote_fingerprint: Some("sha256:fingerprint".to_owned()), + config_generation: Some("config-1".to_owned()), + inventory_generation: Some("inventory-1".to_owned()), + checkout_generation: None, + target_branch: Some("develop".to_owned()), + target_commit: Some("abc123".to_owned()), + instruction_source: Some("AGENTS.md".to_owned()), + instruction_hash: Some("sha256:instructions".to_owned()), + }, + ), + leases: Vec::new(), + repairs: Vec::new(), + memory: None, + containment: None, + provider: None, + verification: None, + cleanup: None, + }, + ); + let store = SnapshotStore::new(snapshot); let server = GatewayServer::new(store.clone()); let listener = TcpListener::bind("127.0.0.1:0") .await @@ -4622,6 +4659,7 @@ async fn gateway_serves_run_detail() { assert_eq!(response.issue_identifier, "COE-255"); assert_eq!(response.turn_count, 3); assert_eq!(response.max_turns, 8); + assert!(response.workspace_path.is_none()); assert_eq!(response.runtime_seconds, 75); assert_eq!( response.branch_name.as_deref(), @@ -4635,15 +4673,22 @@ async fn gateway_serves_run_detail() { response.status, opensymphony::opensymphony_gateway_schema::run::RunStatus::Running ); - // The desktop "Workspace" / "Debug" actions need the on-disk path: the - // workspace root joined with the run's suffix. - assert_eq!( - response.workspace_path.as_deref(), - Some("/tmp/opensymphony/COE-255") - ); + // Exact host paths stay on trusted local diagnostic surfaces, not in the + // gateway's remote run-detail DTO. + assert!(response.workspace_path.is_none()); // An OpenHands run reports the OpenHands harness and no Codex thread id. assert_eq!(response.harness_type.as_deref(), Some("openhands")); assert_eq!(response.codex_thread_id, None); + let repository = response + .operator + .as_ref() + .and_then(|operator| operator.repository.as_ref()) + .expect("operator repository projection"); + assert_eq!(repository.display_alias, "backend"); + assert_eq!(repository.target_commit.as_deref(), Some("abc123")); + let encoded = serde_json::to_value(&response).expect("serialize run detail"); + assert!(encoded.get("remote_url").is_none()); + assert!(encoded.to_string().contains("sha256:fingerprint")); server_task.abort(); } diff --git a/crates/opensymphony-tui/src/lib.rs b/crates/opensymphony-tui/src/lib.rs index 6b31e4212..ebde8191d 100644 --- a/crates/opensymphony-tui/src/lib.rs +++ b/crates/opensymphony-tui/src/lib.rs @@ -744,6 +744,53 @@ impl TuiState { Span::styled(format!("{}", issue.blocked), blocked_style), ])); + if let Some(operator) = issue.operator.as_ref() { + let repository = operator + .repository + .as_ref() + .map(|repository| { + format!( + "{} @ {}", + repository.display_alias, + repository + .target_commit + .as_deref() + .unwrap_or("target unknown") + ) + }) + .unwrap_or_else(|| "repository unknown".to_owned()); + lines.push(Line::from_spans(vec![ + Span::styled("operator: ", Style::new().dim()), + Span::raw(repository), + Span::raw(" | "), + Span::raw( + operator + .containment + .as_ref() + .map(|containment| containment.effective_containment.as_str()) + .unwrap_or("containment unknown"), + ), + ])); + if let Some(parent) = operator.parent.as_ref() { + lines.push(Line::from_spans(vec![ + Span::styled("parent: ", Style::new().dim()), + Span::raw(&parent.parent_id), + Span::raw(" | "), + Span::raw(parent.state.as_deref().unwrap_or("state unknown")), + Span::raw(" | repos: "), + Span::raw(parent.descendant_repositories.len().to_string()), + ])); + } + if let Some(cleanup) = operator.cleanup.as_ref() { + lines.push(Line::from_spans(vec![ + Span::styled("cleanup: ", Style::new().dim()), + Span::raw(&cleanup.status), + Span::raw(" | blockers: "), + Span::raw(cleanup.blockers.len().to_string()), + ])); + } + } + if lines.len() < max_rows { lines.push(Line::from(Span::styled( "-".repeat(width.min(40)), @@ -1096,6 +1143,34 @@ impl TuiState { Span::styled(format!("{}", issue.blocked), blocked_style), ])); + if let Some(operator) = issue.operator.as_ref() { + let repository = operator + .repository + .as_ref() + .map(|repository| { + format!( + "{} @ {}", + repository.display_alias, + repository + .target_commit + .as_deref() + .unwrap_or("target unknown") + ) + }) + .unwrap_or_else(|| "repository unknown".to_owned()); + let containment = operator + .containment + .as_ref() + .map(|containment| containment.effective_containment.as_str()) + .unwrap_or("containment unknown"); + lines.push(Line::from_spans(vec![ + Span::styled("operator: ", Style::new().dim()), + Span::raw(repository), + Span::raw(" | "), + Span::raw(containment), + ])); + } + // Token usage for this issue (always show, even when 0) let cache_suffix = if issue.cache_read_tokens > 0 { format!(" ({} cache)", format_metric(issue.cache_read_tokens)) @@ -4396,6 +4471,7 @@ mod tests { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }) .collect(), diff --git a/crates/opensymphony-tui/tests/reducer.rs b/crates/opensymphony-tui/tests/reducer.rs index 798b38464..250209748 100644 --- a/crates/opensymphony-tui/tests/reducer.rs +++ b/crates/opensymphony-tui/tests/reducer.rs @@ -101,6 +101,7 @@ fn fixture_with_identifiers(sequence: u64, identifiers: &[String]) -> SnapshotEn cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }) .collect(), @@ -164,6 +165,7 @@ fn reordered_fixture(sequence: u64, identifiers: &[&str]) -> SnapshotEnvelope { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }) .collect(); diff --git a/docs/architecture.md b/docs/architecture.md index 538c51b6a..f55a96dde 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,6 +13,11 @@ The system must preserve these boundaries: - `WORKFLOW.md` remains the repo-owned policy and prompt contract - UI is optional and must not affect correctness +The control-plane issue snapshot may carry an optional sanitized operator +projection for repository, parent, lease, repair, memory, containment, +provider, verification, and cleanup facts. Missing facts remain unknown. No +client infers completion, permission, or workspace confinement from absence. + ## 2. Layered design OpenSymphony is split into five layers: diff --git a/docs/configuration.md b/docs/configuration.md index 57aa9f8ba..4c71c8c36 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,6 +3,10 @@ This document covers target-repo bootstrap, generated files, and the runtime configuration that `opensymphony run` expects. +Operator projections expose project and repository identity through safe +aliases, canonical IDs, and safe remote fingerprints. Credential-bearing +remote URLs and raw credential values are excluded. + ## Central configuration `opensymphony run` selects configuration before it reads any repository diff --git a/docs/memory.md b/docs/memory.md index 47021b860..a233c096b 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -8,6 +8,10 @@ descriptions, reviews, checks, and source refs. It writes private issue capsules under `.opensymphony/memory/`, updates a DuckDB index, evolves `.opensymphony/memory/memory.yaml`, and syncs stable topics into public docs. +The operator projection reports memory scope, source freshness, degradation, +and overlay provenance without exposing memory grants or tokens. Memory +availability does not grant filesystem access. + Related specifications: - [OKF Memory System Specification](specs/okf-memory-spec.md) describes how the diff --git a/docs/openhands-agent-server.md b/docs/openhands-agent-server.md index 332045aaa..1f436eb32 100644 --- a/docs/openhands-agent-server.md +++ b/docs/openhands-agent-server.md @@ -33,6 +33,10 @@ OpenHands provides the execution-layer primitives Symphony needs: OpenSymphony keeps all scheduling rules in Rust and uses OpenHands only as the runtime substrate. +Operator snapshots label the requested execution scope separately from the +harness's effective containment. Trusted-host execution is reported honestly +and is not presented as workspace confinement. + ## 3. Runtime modes ### Local supervised mode diff --git a/docs/operations.md b/docs/operations.md index 3eb2af5d1..82e522080 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,6 +2,11 @@ This document covers the current local operator workflow for OpenSymphony. +Run detail and TUI projections expose sanitized repository, target commit, +instruction hash, lease, repair, verification, and cleanup facts when those +facts are authoritative. Blocked and cleaning states remain distinct from +completed, and remote views do not expose unrestricted local paths. + Packaging note: crates.io publishes one package, `opensymphony`. The internal `crates/opensymphony-*` directories are module trees inside that package, not separately published dependencies. diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 50ac7c1ee..162b38498 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -15,6 +15,10 @@ OpenSymphony sits at the intersection of: The project needs more than unit tests. It needs layered validation with deterministic fakes and opt-in live tests. +Projection tests must cover round trips, blocked-state rendering, local versus +remote path boundaries, and secret-canary absence across Rust, TypeScript, TUI, +web, and desktop surfaces. + ## 2. Test layers ## 2.1 Unit tests diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 09ef36c74..f9874e4ba 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -4,6 +4,10 @@ Preserve the Symphony workspace contract while adapting it to OpenHands conversation persistence and local MVP safety constraints. +Remote operator views receive repository aliases, canonical IDs, and checkout +generations when authoritative state provides them. Exact local paths remain +limited to trusted local diagnostics. + ## 2. Workspace mapping Each issue maps to exactly one workspace path: diff --git a/packages/gateway-schema/__tests__/fixtures.test.ts b/packages/gateway-schema/__tests__/fixtures.test.ts index 2ebcc80d8..94b74fff4 100644 --- a/packages/gateway-schema/__tests__/fixtures.test.ts +++ b/packages/gateway-schema/__tests__/fixtures.test.ts @@ -243,11 +243,14 @@ describe("run detail fixture", () => { }); test("run_detail has expected fields", () => { - const data = loadFixture("run_detail.json") as Record; + const data = loadFixture("run_detail.json") as Record; expect(data.status).toBe("running"); expect(data.issue_identifier).toBe("COE-390"); expect(data.turn_count).toBe(3); expect(data.max_turns).toBe(8); + expect(data.operator.repository.display_alias).toBe("backend"); + expect(data.operator.containment.effective_containment).toBe("trusted_host"); + expect(JSON.stringify(data)).not.toContain("remote_url"); }); }); diff --git a/packages/gateway-schema/__tests__/fixtures/run_detail.json b/packages/gateway-schema/__tests__/fixtures/run_detail.json index 07fb444d8..09d0d29ba 100644 --- a/packages/gateway-schema/__tests__/fixtures/run_detail.json +++ b/packages/gateway-schema/__tests__/fixtures/run_detail.json @@ -14,5 +14,24 @@ "cache_read_tokens": 256, "runtime_seconds": 120, "conversation_id": "conv-1", - "workspace_path": "/tmp/workspaces/COE-390" -} \ No newline at end of file + "workspace_path": "/tmp/workspaces/COE-390", + "operator": { + "routing_mode": "project_set", + "active_project_set": ["project-a"], + "linear_project": "project-a", + "repository": { + "canonical_id": "github:repository:390", + "display_alias": "backend", + "safe_remote_fingerprint": "sha256:fingerprint", + "target_branch": "develop", + "target_commit": "abc123", + "instruction_hash": "sha256:instructions" + }, + "leases": [], + "repairs": [], + "containment": { + "requested_scope": "trusted_host", + "effective_containment": "trusted_host" + } + } +} diff --git a/packages/gateway-schema/src/index.ts b/packages/gateway-schema/src/index.ts index 1a8cc98cb..908d5715e 100644 --- a/packages/gateway-schema/src/index.ts +++ b/packages/gateway-schema/src/index.ts @@ -109,6 +109,16 @@ export type { RunLifecycleState, RunAction, RunDetail, + RunOperatorSnapshot, + RunRepositorySnapshot, + RunParentSnapshot, + RunLeaseSnapshot, + RunRepairSnapshot, + RunMemorySnapshot, + RunContainmentSnapshot, + RunProviderSnapshot, + RunVerificationSnapshot, + RunCleanupSnapshot, RunEventPage, RunEvent, FileChangeKind, diff --git a/packages/gateway-schema/src/run.ts b/packages/gateway-schema/src/run.ts index 7bedb9d90..f37303ebb 100644 --- a/packages/gateway-schema/src/run.ts +++ b/packages/gateway-schema/src/run.ts @@ -183,6 +183,92 @@ export interface RunDetail { cancel_timed_out?: boolean; /** Machine-readable cancel reason supplied by the orchestrator. */ cancel_reason?: string | null; + /** Sanitized multi-repository and parent lifecycle facts. */ + operator?: RunOperatorSnapshot | null; +} + +export interface RunOperatorSnapshot { + routing_mode?: string | null; + active_project_set: string[]; + linear_project?: string | null; + binding_status?: string | null; + parent?: RunParentSnapshot | null; + repository?: RunRepositorySnapshot | null; + leases: RunLeaseSnapshot[]; + repairs: RunRepairSnapshot[]; + memory?: RunMemorySnapshot | null; + containment?: RunContainmentSnapshot | null; + provider?: RunProviderSnapshot | null; + verification?: RunVerificationSnapshot | null; + cleanup?: RunCleanupSnapshot | null; +} + +export interface RunRepositorySnapshot { + canonical_id: string; + display_alias: string; + safe_remote_fingerprint?: string | null; + config_generation?: string | null; + inventory_generation?: string | null; + checkout_generation?: string | null; + target_branch?: string | null; + target_commit?: string | null; + instruction_source?: string | null; + instruction_hash?: string | null; +} + +export interface RunParentSnapshot { + parent_id: string; + state?: string | null; + hierarchy_generation?: number | null; + blocked_reason?: string | null; + descendant_repositories: string[]; + checkout_handles: string[]; +} + +export interface RunLeaseSnapshot { + owner_id: string; + owner_kind: string; + repository_id: string; + checkout_generation: string; +} + +export interface RunRepairSnapshot { + id: string; + repository_id?: string | null; + status: string; + pull_request_url?: string | null; + target_commit?: string | null; + instruction_hash?: string | null; +} + +export interface RunMemorySnapshot { + scope: string; + source_freshness?: string | null; + degraded: boolean; + overlay_provenance?: string | null; +} + +export interface RunContainmentSnapshot { + requested_scope?: string | null; + effective_containment: string; +} + +export interface RunProviderSnapshot { + provider: string; + step: string; + status: string; +} + +export interface RunVerificationSnapshot { + attempts: number; + status: string; + final_evidence?: string | null; +} + +export interface RunCleanupSnapshot { + status: string; + blockers: string[]; + retry_count: number; } /** Paged run events. */ diff --git a/packages/ui-core/src/app-shell.ts b/packages/ui-core/src/app-shell.ts index 822e79ec2..737e5489f 100644 --- a/packages/ui-core/src/app-shell.ts +++ b/packages/ui-core/src/app-shell.ts @@ -3687,9 +3687,28 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { run.diagnostics?.cancel_acknowledged ? `
Cancelacknowledged
` : "", run.diagnostics?.cancel_failed ? `
Cancelfailed
` : "", ].filter(Boolean).join(""); + const operator = run.operator; + const leases = operator?.leases ?? []; + const repairs = operator?.repairs ?? []; + const cleanupBlockers = operator?.cleanup?.blockers ?? []; + const descendantRepositories = operator?.parent?.descendant_repositories ?? []; + const checkoutHandles = operator?.parent?.checkout_handles ?? []; const runMeta = [ + operator?.routing_mode ? `
Routing${escapeHtml(operator.routing_mode)}${operator.active_project_set?.length ? `${escapeHtml(operator.active_project_set.join(", "))}` : ""}
` : "", + operator?.linear_project ? `
Linear project${escapeHtml(operator.linear_project)}
` : "", run.branch_name ? `
Branch${escapeHtml(run.branch_name)}
` : "", (run.branch_name || run.pr_url) ? `
Pull Request${run.pr_url ? `${escapeHtml(formatPrLinkLabel(run.pr_url))}` : `Not found`}
` : "", + run.operator?.repository ? `
Repository${escapeHtml(run.operator.repository.display_alias)}${escapeHtml(run.operator.repository.canonical_id)}
` : "", + run.operator?.repository?.target_commit ? `
Target commit${escapeHtml(run.operator.repository.target_commit)}
` : "", + run.operator?.repository?.instruction_hash ? `
Instructions${escapeHtml(run.operator.repository.instruction_hash)}
` : "", + run.operator?.containment ? `
Containment${escapeHtml(run.operator.containment.effective_containment)}
` : "", + operator?.parent ? `
Parent${escapeHtml(operator.parent.parent_id)}${operator.parent.state ? `${escapeHtml(operator.parent.state)}` : ""}${operator.parent.blocked_reason ? `${escapeHtml(operator.parent.blocked_reason)}` : ""}${descendantRepositories.length ? `repos: ${escapeHtml(descendantRepositories.join(", "))}` : ""}${checkoutHandles.length ? `checkouts: ${escapeHtml(checkoutHandles.join(", "))}` : ""}
` : "", + leases.length ? `
Cleanup leases${leases.map((lease) => escapeHtml(`${lease.owner_id} (${lease.repository_id})`)).join(", ")}
` : "", + repairs.length ? `
Repairs${repairs.map((repair) => escapeHtml(`${repair.id}: ${repair.status}`)).join(", ")}${repairs.map((repair) => repair.pull_request_url ? `${escapeHtml(formatPrLinkLabel(repair.pull_request_url))}` : "").join("")}
` : "", + operator?.memory ? `
Memory${escapeHtml(operator.memory.scope)}${operator.memory.degraded ? `degraded` : ""}${operator.memory.source_freshness ? `${escapeHtml(operator.memory.source_freshness)}` : ""}
` : "", + operator?.provider ? `
Provider${escapeHtml(`${operator.provider.provider}: ${operator.provider.step}`)}${escapeHtml(operator.provider.status)}
` : "", + operator?.verification ? `
Verification${escapeHtml(operator.verification.status)}${operator.verification.attempts} attempt(s)${operator.verification.final_evidence ? `${escapeHtml(operator.verification.final_evidence)}` : ""}
` : "", + operator?.cleanup ? `
Cleanup${escapeHtml(operator.cleanup.status)}${cleanupBlockers.length ? `${escapeHtml(cleanupBlockers.join(", "))}` : ""}
` : "", ].filter(Boolean).join(""); const receipt = this.state.lastActionReceipt ? renderActionReceipt(this.state.lastActionReceipt) diff --git a/tests/action_handler.rs b/tests/action_handler.rs index c80016cb1..0a2bd9d4e 100644 --- a/tests/action_handler.rs +++ b/tests/action_handler.rs @@ -89,6 +89,7 @@ fn fixture_snapshot( cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }], recent_events: Vec::new(), diff --git a/tests/task_graph_mutations_routes.rs b/tests/task_graph_mutations_routes.rs index 29e24c987..419253d01 100644 --- a/tests/task_graph_mutations_routes.rs +++ b/tests/task_graph_mutations_routes.rs @@ -117,6 +117,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { cancel_failed: false, cancel_timed_out: false, cancel_reason: None, + operator: None, detached: false, }], recent_events: vec![RecentEvent { From 70619ec9d0156295b97b922a6bde14d7335917cf Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Tue, 22 Sep 2026 06:34:45 -0500 Subject: [PATCH 11/29] feat(acp): add supervised ACP v1 client and launch profiles (#241) * docs(acp): define runtime and IDE attachment implementation waves Record the ACP runtime and IDE attachment specifications, task contracts, dependency sequencing, and published Linear mappings. Reconcile the shared planning snapshot with develop while preserving upstream documentation. Validation: both ACP and advanced knowledge/debug task manifests validate; git diff --check passes. This commit does not implement dependent tasks. * feat(acp): add supervised ACP v1 client and launch profiles Use the official pinned Rust SDK for initialize, authentication, session creation, ordered callbacks, prompt completion, and confirmed cancellation. Validate host workspace binding and secret-free profile references before launch; bound transport and evidence, redact diagnostics, and supervise process-tree teardown with explicit outcome uncertainty. Add a reusable adversarial subprocess and central/workflow configuration coverage. Keep production routing and durable sessions in dependent tasks. Document the implemented API and its limits; box the testkit event payload because the SDK JSON map feature increases its enum size. Validation: 2071 bundled tests passed, 4 existing ignored; bundled and system Clippy clean; 9 ACP, 67 workflow and 1 central focused tests pass. Formatting, diff checks, and both planning manifests pass. Full tests use an environment without ambient Linear OAuth credentials. * fix(acp): preserve stream content and cancel setup promptly Address the first full PR review in one batch: - Stop cancelled launches before spawn and interrupt pending setup. - Preserve ACP profiles and selectors during central config migration. - Keep live update text intact while redacting secrets; validate update payloads even when the caller does not request a stream. - Mark submission only after the encoded prompt passes its frame bound. Reject known host credentials in argv and keep untrusted negotiation metadata out of diagnostic Debug output. Add focused subprocess and migration regressions and update the runtime documentation. Validation: 2076 bundled tests passed, 4 existing ignored; 13 ACP tests and the migration regression passed; system and bundled Clippy, fmt and diff checks passed. Full tests exclude ambient Linear OAuth credentials. * fix(acp): scope credentials and preserve auth uncertainty Remove env-ref source aliases from the child environment unless explicitly selected as targets. Carry prompt submission state on authentication errors so a mid-turn credential failure cannot imply a safe retry. Keep opaque session identifiers out of SessionUpdate Debug output while retaining them for protocol correlation. Add subprocess regressions for renamed/retained environment sources, authentication failure after prompt submission, and credential-bearing session IDs. Update configuration and diagnostic documentation. Validation: 2079 bundled tests passed, 4 existing ignored; 16 ACP tests passed; system and bundled Clippy, fmt and diff checks passed. Full tests exclude ambient Linear OAuth credentials. * fix(acp): own Windows process trees and resolve routing overrides Use a kill-on-close Job Object before Windows children execute and retain one termination/reaping deadline. Add native Windows lifecycle CI with production-source and root-lockfile validation. Reuse platform-aware names when resolving environment references. Validate ACP selection after routing environment overrides, preserve profile validation causes, reject mixed-case credential flags, and list ACP in the supported-harness diagnostic. Validation: 2079 bundled tests passed (4 existing ignored), ACP 16 and central ACP 1 passed; bundled/system Clippy and fmt/diff checks clean. Windows helper and four lifecycle regressions cross-compile; native CI execution follows this push. * fix(acp): replace environment aliases with explicit values Use the shared platform-aware name comparison when applying environment aliases, so inherited Windows casing cannot override mapped credentials. Move the comparison and replacement into a small workspace environment module and run its real child-environment regression in Windows CI. Name the missing required agent capability in setup errors and cover all three supported capability requirements with the fake ACP peer. Validation: 2080 bundled tests passed (4 existing ignored), ACP 16 and child-environment regression passed; system/bundled Clippy and fmt/diff checks clean. Production Windows sources cross-compile; native CI follows. * fix(acp): clear profile for non-ACP harness overrides Drop the ACP selector when an explicit environment override selects a non-ACP harness. Retain the profile for ACP overrides and continue to reject incompatible static harness/profile combinations. Add regressions for ACP, OpenHands and Codex override targets and document the resolved selector behavior. Validation: ACP 16 and bundled 2080 tests passed (4 existing ignored). System/bundled Clippy, formatting and diff checks are clean. * fix(acp): enforce encoded prompt and cumulative evidence limits Admit prompt text based on the complete serialized wire frame instead of rejecting otherwise valid text above half the configured frame size. Cap retained redacted source frames by cumulative serialized bytes, including metadata, independently from their count. Keep processing the protocol when capture truncates and allow zero-byte retention. Add exact byte-boundary and legal-frame flood regressions, plus a prompt admission case alongside the escaped-overflow submission-state check. Document the 1 MiB default and 16 MiB maximum evidence budgets. Validation: ACP subprocess tests (18), evidence boundary unit test, system and bundled Clippy, and full bundled tests. * fix(acp): bound live queues and harden credential configuration Charge incoming wire bytes until ordered SDK dispatch, with independent frame-count and byte limits. Verify queue exhaustion and charge release without relying on retained diagnostic evidence. Share normalized sensitive-field classification between central config and ACP payload redaction, covering account identities in diagnostics and live updates. Reject alternate credential flags in profile argv and recognize ACP-only central files before permissive legacy fallback. Add adversarial subprocess, exact-budget and runtime-config regressions; document the new queue budget and fail-closed configuration behavior. Validation: ACP subprocess tests (20), ACP unit/config tests (5), system and bundled Clippy, and full bundled tests. * fix(acp): bound redaction and preserve RPC diagnostics Replace per-secret string rescans with a bounded Aho-Corasick matcher. Redact known secrets before limiting generic diagnostic normalization, and preserve overlapping-secret protection without replacement rescans. Reject configured environment targets that collide under host name rules before insertion. Share the production collision check with native Windows tests while retaining POSIX case distinctions. Keep RPC method, code, bounded redacted message and submission state in an explicit error even when source evidence retention is disabled. Preserve authentication and transport-disconnection classifications. Validation: ACP subprocess tests (22), ACP/config units (6), environment unit, Windows helper cross-compilation (6 native cases), both Clippy variants, and full bundled tests. The unit batch including a 16 MiB redaction input with 128-plus patterns completed locally in 0.05 seconds. * fix(acp): order session binding and bound callback output Bind new-session ownership inside the SDK ordered response callback before adjacent notifications can dispatch. Reserve callback frame count and encoded bytes before SDK enqueue, retaining each reservation until stdin flush completes so an unread peer cannot grow outgoing queues indefinitely. Cover adjacent response/update frames, blocked output for unknown and permission requests, exact encoded-byte boundaries, and reservation reuse. Document the independent callback budget and fail saturation promptly. Validation: 25 ACP subprocess tests, 7 ACP/config unit tests, system check, system and bundled Clippy, full bundled tests, fmt and diff checks. --- .github/workflows/ci.yml | 10 + Cargo.lock | 778 ++++++++++- Cargo.toml | 8 +- crates/opensymphony-acp/src/lib.rs | 1148 +++++++++++++++++ .../opensymphony-acp/src/windows_process.rs | 151 +++ crates/opensymphony-cli/src/migration.rs | 49 + .../src/orchestrator_run/config.rs | 227 +++- .../src/scheduler.rs | 1 + .../tests/scheduler.rs | 1 + crates/opensymphony-testkit/src/lib.rs | 4 +- crates/opensymphony-workflow/src/acp.rs | 194 +++ crates/opensymphony-workflow/src/lib.rs | 2 + crates/opensymphony-workflow/src/model.rs | 5 + crates/opensymphony-workflow/src/resolve.rs | 13 +- .../opensymphony-workspace/src/environment.rs | 83 ++ crates/opensymphony-workspace/src/lib.rs | 13 +- crates/opensymphony-workspace/src/manager.rs | 16 +- crates/opensymphony-workspace/src/models.rs | 8 - .../src/sensitive_fields.rs | 59 + docs/architecture.md | 16 + docs/configuration.md | 63 + docs/harness-adapter-compatibility.md | 9 + docs/implementation-plan.md | 30 +- docs/operations.md | 55 + docs/sources.md | 20 + docs/specs/acp-harness-adapter.md | 485 +++++++ docs/specs/opensymphony-acp-debugging-spec.md | 1087 ++++------------ .../tasks/acp-runtime-ide-linear-publish.yaml | 81 ++ docs/tasks/acp-runtime-ide-task-package.yaml | 34 + ...advanced-knowledge-debug-task-package.yaml | 14 +- docs/tasks/milestones.md | 33 +- ...osym-840-debug-attachment-core-refactor.md | 63 +- ...m-841-acp-stdio-server-protocol-adapter.md | 67 +- ...static-agent-configuration-and-setup-ux.md | 59 +- ...ym-843-tauri-debug-in-zed-launch-action.md | 60 +- ...bug-ux-transition-and-cli-compatibility.md | 58 +- ...-integration-tests-and-failure-guidance.md | 65 +- ...profiles-and-executable-protocol-client.md | 66 + ...-session-ownership-and-durable-recovery.md | 67 + ...xecution-routing-and-worker-integration.md | 70 + ...ent-callbacks-and-session-configuration.md | 69 + ...-operator-requests-and-response-routing.md | 72 ++ ...5-acp-extensions-and-harness-operations.md | 68 + ...time-conformance-and-live-qualification.md | 70 + ...g-control-handoff-and-scheduler-fencing.md | 69 + docs/testing-and-operations.md | 30 + docs/workspace-and-lifecycle.md | 20 + scripts/validation/check-acp-windows.py | 52 + src/lib.rs | 2 + tests/acp.rs | 1067 +++++++++++++++ tests/fixtures/acp_peer.py | 185 +++ 51 files changed, 5798 insertions(+), 1178 deletions(-) create mode 100644 crates/opensymphony-acp/src/lib.rs create mode 100644 crates/opensymphony-acp/src/windows_process.rs create mode 100644 crates/opensymphony-workflow/src/acp.rs create mode 100644 crates/opensymphony-workspace/src/environment.rs create mode 100644 crates/opensymphony-workspace/src/sensitive_fields.rs create mode 100644 docs/specs/acp-harness-adapter.md create mode 100644 docs/tasks/acp-runtime-ide-linear-publish.yaml create mode 100644 docs/tasks/acp-runtime-ide-task-package.yaml create mode 100644 docs/tasks/osym-900-acp-profiles-and-executable-protocol-client.md create mode 100644 docs/tasks/osym-901-acp-session-ownership-and-durable-recovery.md create mode 100644 docs/tasks/osym-902-acp-execution-routing-and-worker-integration.md create mode 100644 docs/tasks/osym-903-acp-client-callbacks-and-session-configuration.md create mode 100644 docs/tasks/osym-904-acp-operator-requests-and-response-routing.md create mode 100644 docs/tasks/osym-905-acp-extensions-and-harness-operations.md create mode 100644 docs/tasks/osym-906-acp-runtime-conformance-and-live-qualification.md create mode 100644 docs/tasks/osym-907-debug-control-handoff-and-scheduler-fencing.md create mode 100644 scripts/validation/check-acp-windows.py create mode 100644 tests/acp.rs create mode 100644 tests/fixtures/acp_peer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03cc392a1..611601fa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,16 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: test run: cargo test --workspace + acp-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.97.1 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: ACP Windows process ownership regressions + run: python scripts/validation/check-acp-windows.py typescript: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index 0faa8dde3..50748b3d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,56 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "rustix 1.1.4", + "schemars 1.2.2", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "ahash" version = "0.7.8" @@ -219,7 +269,7 @@ dependencies = [ "arrow-schema", "arrow-select", "atoi", - "base64", + "base64 0.22.1", "chrono", "comfy-table", "half", @@ -273,7 +323,7 @@ version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -307,6 +357,83 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -329,6 +456,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -369,7 +502,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -434,6 +567,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -449,6 +588,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -476,6 +621,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -506,6 +664,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -647,6 +814,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-random" version = "0.1.18" @@ -710,13 +886,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "parking_lot", "rustix 0.38.44", @@ -729,7 +911,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", @@ -766,12 +948,77 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deranged" version = "0.5.8" @@ -779,6 +1026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -812,6 +1060,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -859,9 +1108,15 @@ dependencies = [ "libduckdb-sys", "num-integer", "rust_decimal", - "strum", + "strum 0.27.2", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -893,6 +1148,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -949,6 +1224,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1036,7 +1317,7 @@ checksum = "07f72a180b002371a31ad286c396801adc47a095df6a4f5f5d3e4f91ee0a3c06" dependencies = [ "ahash 0.8.12", "arc-swap", - "bitflags", + "bitflags 2.11.0", "crossterm 0.29.0", "signal-hook 0.4.3", "unicode-display-width", @@ -1078,7 +1359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66559719b7cf30a9824fcb368b82e36eacee7178ef85c8bc2ec4c588dc3dfdf9" dependencies = [ "ahash 0.8.12", - "bitflags", + "bitflags 2.11.0", "bumpalo", "ftui-core", "memchr", @@ -1144,7 +1425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5cd953320541aafc80ca0546ff26a9478e161baf421d9e0372c2f503b1e881b" dependencies = [ "ahash 0.8.12", - "bitflags", + "bitflags 2.11.0", "ftui-core", "ftui-layout", "ftui-render", @@ -1161,50 +1442,102 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -1214,10 +1547,11 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1340,6 +1674,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -1430,7 +1776,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1558,6 +1904,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1579,6 +1931,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1628,6 +1991,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1756,9 +2173,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libduckdb-sys" @@ -1952,6 +2369,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2079,6 +2508,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" name = "opensymphony" version = "2.11.3" dependencies = [ + "agent-client-protocol", + "agent-client-protocol-schema", + "aho-corasick", "async-stream", "async-trait", "axum", @@ -2092,6 +2524,7 @@ dependencies = [ "jsonschema", "liquid", "lru", + "process-wrap", "reqwest", "reqwest-eventsource", "rustix 1.1.4", @@ -2125,6 +2558,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2197,6 +2636,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2209,12 +2668,52 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2267,6 +2766,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e3f4237d0e4741eb50bc5584db701f1299c85fa31ff0274dd6445e79dc42d12" +dependencies = [ + "futures", + "indexmap 2.13.0", + "nix", + "tokio", + "windows", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2434,7 +2946,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2518,7 +3030,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -2661,7 +3173,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2674,7 +3186,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2749,6 +3261,43 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2767,7 +3316,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -2820,13 +3369,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap", + "indexmap 2.13.0", "itoa", "memchr", "serde", @@ -2866,13 +3426,46 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -2910,6 +3503,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3027,7 +3626,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -3042,6 +3650,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3070,6 +3690,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3336,7 +3967,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -3350,7 +3981,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.2", @@ -3393,7 +4024,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -3813,7 +4444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -3837,9 +4468,9 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -3894,6 +4525,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3907,6 +4559,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3935,6 +4598,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4013,6 +4686,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4155,7 +4837,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -4185,8 +4867,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.11.0", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -4205,7 +4887,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -4352,7 +5034,7 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap", + "indexmap 2.13.0", "memchr", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 544f6f59b..7bf9e995b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ duckdb-prebuilt = [] openhands-subscription-credentials = [] [dependencies] +agent-client-protocol = "=2.2.0" +agent-client-protocol-schema = "=1.9.1" +aho-corasick = "=1.1.4" async-stream.workspace = true async-trait.workspace = true axum.workspace = true @@ -68,6 +71,9 @@ uuid.workspace = true [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs"] } +[target.'cfg(windows)'.dependencies] +process-wrap = { version = "=10.0.0", default-features = false, features = ["tokio1", "job-object", "kill-on-drop"] } + [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } @@ -120,7 +126,7 @@ thiserror = "2" time = "=0.3.47" tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] } -tokio-util = { version = "0.7", features = ["io"] } +tokio-util = { version = "0.7", features = ["io", "codec"] } toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs new file mode 100644 index 000000000..c3e598f40 --- /dev/null +++ b/crates/opensymphony-acp/src/lib.rs @@ -0,0 +1,1148 @@ +//! Executable ACP v1 stdio client. Durable ownership and worker routing live in the host. +//! +//! `run_turn` owns one process for one complete turn; it never resumes or retries a prompt. +//! A protocol stop reason is distinct from proof that remote delegated work has stopped. +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + io, + path::PathBuf, + process::Stdio, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use agent_client_protocol::{ + Client, Dispatch, Lines, RawJsonRpcMessage, UntypedMessage, + schema::{ + ProtocolVersion, + v1::{ + AuthMethod, AuthenticateRequest, CancelNotification, ContentBlock, InitializeRequest, + InitializeResponse, NewSessionRequest, PromptRequest, RequestPermissionOutcome, + RequestPermissionRequest, RequestPermissionResponse, TextContent, + }, + }, +}; +use aho_corasick::{AhoCorasick, AhoCorasickKind, MatchKind}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; +use tokio::{ + io::{AsyncReadExt, BufReader}, + process::Command, + sync::{mpsc, oneshot}, +}; +use tokio_util::{ + codec::{FramedRead, FramedWrite, LinesCodec}, + sync::CancellationToken, +}; +use tracing::instrument::WithSubscriber; + +#[cfg(windows)] +mod windows_process; + +pub use crate::opensymphony_workflow::AcpProfile; +use crate::opensymphony_workspace::{ + environment_variable_names_equal, has_environment_name_collision, insert_environment_value, + redact_runtime_diagnostic, runtime_field_is_sensitive, sanitize_workspace_key, +}; + +#[cfg(not(windows))] +use crate::opensymphony_workspace::{configure_process_group, terminate_process_tree}; + +pub const SDK_VERSION: &str = "2.2.0"; +pub const SCHEMA_VERSION: &str = "1.9.1"; + +/// Host-owned launch inputs; neither cwd nor resolved secrets belong in a profile. +/// `workspace_key` is the scheduler's sanitized checkout key (including repository suffixes). +pub struct LaunchContext { + pub workspace_root: PathBuf, + pub workspace_key: String, + pub issue_workspace: PathBuf, + pub environment: BTreeMap, + pub excluded_environment: BTreeSet, +} + +#[derive(Debug, Clone)] +pub struct ClientLimits { + pub frame_bytes: usize, + pub queued_frames: usize, + /// Cumulative wire bytes awaiting SDK dispatch, independently of frame count. + pub queued_bytes: usize, + /// Callback responses awaiting completed stdin writes, including SDK-internal queues. + pub callback_frames: usize, + /// Cumulative encoded callback response bytes, including LF delimiters. + pub callback_bytes: usize, + pub evidence_frames: usize, + /// Cumulative serialized bytes of retained, redacted SourceFrame values. + pub evidence_bytes: usize, + pub stderr_bytes: usize, + pub setup_timeout: Duration, + pub prompt_timeout: Duration, + pub cancel_timeout: Duration, + pub reap_timeout: Duration, +} + +impl Default for ClientLimits { + fn default() -> Self { + Self { + frame_bytes: 1024 * 1024, + queued_frames: 128, + queued_bytes: 4 * 1024 * 1024, + callback_frames: 128, + callback_bytes: 4 * 1024 * 1024, + evidence_frames: 256, + evidence_bytes: 1024 * 1024, + stderr_bytes: 16 * 1024, + setup_timeout: Duration::from_secs(30), + prompt_timeout: Duration::from_secs(300), + cancel_timeout: Duration::from_secs(10), + reap_timeout: Duration::from_secs(5), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ClientError { + #[error("invalid ACP launch configuration: {0}")] + InvalidConfiguration(String), + #[error( + "ACP workspace must be the existing canonical scheduler-bound checkout under its workspace root" + )] + InvalidWorkspace, + #[error("ACP launch failed ({0:?})")] + Launch(io::ErrorKind), + #[error("ACP peer closed its output; prompt may have been submitted: {submitted}")] + Disconnected { submitted: bool }, + #[error("ACP setup failed: {0}")] + Setup(String), + #[error( + "ACP authentication is required; prompt may have been submitted: {submitted}; configure an advertised noninteractive auth.method_id or establish agent login before launch" + )] + AuthenticationRequired { submitted: bool }, + #[error("ACP setup deadline exceeded")] + SetupTimeout, + #[error("ACP cancelled before prompt submission")] + CancelledBeforePrompt, + #[error("ACP transport or RPC failed; prompt may have been submitted: {submitted}")] + Protocol { submitted: bool }, + #[error( + "ACP {method} RPC error {code}: {message}; prompt may have been submitted: {submitted}" + )] + Rpc { + method: &'static str, + code: i32, + message: String, + submitted: bool, + }, + #[error("ACP prompt deadline exceeded; outcome is uncertain")] + PromptTimeout, + #[error("ACP cancellation deadline exceeded; cancellation is unacknowledged")] + CancelTimeout, + #[error( + "ACP input/output resource limit exceeded; prompt may have been submitted: {submitted}" + )] + ResourceLimit { submitted: bool }, + #[error("ACP child could not be terminated and reaped")] + Teardown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceFrame { + pub sequence: u64, + pub direction: String, + pub observed_at: chrono::DateTime, + pub payload: Value, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct SessionUpdate { + pub session_id: String, + pub update: Value, +} + +impl std::fmt::Debug for SessionUpdate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionUpdate") + .field("update", &self.update) + .finish_non_exhaustive() + } +} + +#[derive(Clone)] +pub struct TurnReport { + /// Only `end_turn` is a successful turn. Unknown values remain unsupported outcomes. + pub stop_reason: String, + pub cancellation_requested: bool, + pub cancellation_acknowledged: bool, + pub session_id: String, + pub initialization: InitializeResponse, +} + +// Negotiation metadata and opaque peer identifiers are operational values, not +// diagnostics: a peer can echo credentials in either. Captured frames are redacted. +impl std::fmt::Debug for TurnReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TurnReport") + .field("succeeded", &self.succeeded()) + .field("cancellation_requested", &self.cancellation_requested) + .field("cancellation_acknowledged", &self.cancellation_acknowledged) + .finish_non_exhaustive() + } +} + +impl TurnReport { + pub fn succeeded(&self) -> bool { + self.stop_reason == "end_turn" + } +} + +#[derive(Debug)] +pub struct RunResult { + pub outcome: Result, + pub evidence: Vec, + pub evidence_truncated: bool, + pub stderr: String, + pub process_reaped: bool, + /// Signalling a departed process group can fail independently of reaping its child. + pub process_tree_signal_error: Option, +} + +/// Reservations precede SDK enqueue and survive every internal queue until stdin flush. +/// Retaining the SDK's exact encoding also rejects unreserved SDK-generated responses. +#[derive(Default)] +struct CallbackOutput { + frames: VecDeque, + bytes: usize, +} + +impl CallbackOutput { + fn admit(&mut self, frame: String, limits: &ClientLimits) -> bool { + let bytes = frame.len() + 1; // LinesCodec adds LF. + if frame.len() > limits.frame_bytes + || self.frames.len() >= limits.callback_frames + || bytes > limits.callback_bytes.saturating_sub(self.bytes) + { + return false; + } + self.bytes += bytes; + self.frames.push_back(frame); + true + } + + fn matches_front(&self, frame: &str) -> bool { + self.frames + .front() + .is_some_and(|expected| expected == frame) + } + + fn flushed(&mut self) { + if let Some(frame) = self.frames.pop_front() { + self.bytes -= frame.len() + 1; + } + } +} + +#[derive(Default)] +struct QueuedInput { + frame_sizes: VecDeque, + bytes: usize, +} + +impl QueuedInput { + fn admit(&mut self, bytes: usize, max_frames: usize, max_bytes: usize) -> bool { + if self.frame_sizes.len() >= max_frames || bytes > max_bytes.saturating_sub(self.bytes) { + return false; + } + self.bytes += bytes; + self.frame_sizes.push_back(bytes); + true + } + + fn dispatched(&mut self) { + // The SDK dispatch callback applies every accepted frame in wire order. + if let Some(bytes) = self.frame_sizes.pop_front() { + self.bytes -= bytes; + } + } +} + +struct SecretRedactor { + matcher: AhoCorasick, +} + +impl SecretRedactor { + fn new(mut secrets: Vec) -> Result { + secrets.sort(); + secrets.dedup(); + if secrets.len() > 1024 + || secrets + .iter() + .map(String::len) + .fold(0usize, usize::saturating_add) + > 1024 * 1024 + { + return Err(ClientError::InvalidConfiguration( + "credential redaction inputs exceed the 1024-pattern or 1 MiB budget".into(), + )); + } + let matcher = AhoCorasick::builder() + .match_kind(MatchKind::LeftmostLongest) + .kind(Some(AhoCorasickKind::ContiguousNFA)) + .build(secrets) + .map_err(|_| { + ClientError::InvalidConfiguration( + "credential redaction matcher could not be built".into(), + ) + })?; + Ok(Self { matcher }) + } + + fn contains_secret(&self, text: &str) -> bool { + self.matcher.is_match(text) + } + + fn redact(&self, text: &str) -> String { + let mut result = String::with_capacity(text.len()); + self.matcher + .replace_all_with(text, &mut result, |_, _, output| { + output.push_str("[redacted]"); + true + }); + result + } +} + +struct Capture { + frames: Vec, + sequence: u64, + truncated: bool, + max: usize, + bytes: usize, + max_bytes: usize, + secrets: SecretRedactor, +} + +impl Capture { + fn redact(&self, value: &mut Value, diagnostic: bool) { + match value { + Value::String(s) => { + *s = self.secrets.redact(s); + if diagnostic { + // Known-secret matching sees the complete value before truncation; + // generic preview normalization only examines a bounded prefix. + let preview = s.chars().take(2048).collect::(); + *s = redact_runtime_diagnostic(&preview); + } + } + Value::Array(values) => values.iter_mut().for_each(|v| self.redact(v, diagnostic)), + Value::Object(values) => { + for (mut key, mut value) in std::mem::take(values) { + if runtime_field_is_sensitive(&key) + || [ + "token", + "secret", + "password", + "authorization", + "api_key", + "apikey", + "credential", + ] + .iter() + .any(|s| key.to_ascii_lowercase().contains(s)) + { + value = json!("[redacted]"); + } else { + self.redact(&mut value, diagnostic); + } + key = self.secrets.redact(&key); + values.insert(key, value); + } + } + _ => {} + } + } + fn record(&mut self, direction: &str, mut payload: Value) { + self.sequence += 1; + if self.frames.len() == self.max || self.bytes == self.max_bytes { + self.truncated = true; + return; + } + self.redact(&mut payload, true); + self.retain_frame(SourceFrame { + sequence: self.sequence, + direction: direction.into(), + observed_at: chrono::Utc::now(), + payload, + }); + } + + fn retain_frame(&mut self, frame: SourceFrame) { + let Ok(encoded) = serde_json::to_vec(&frame) else { + self.truncated = true; + return; + }; + if encoded.len() > self.max_bytes.saturating_sub(self.bytes) { + self.truncated = true; + return; + } + self.bytes += encoded.len(); + self.frames.push(frame); + } +} + +type SharedCapture = Arc>; + +fn capture(capture: &SharedCapture, direction: &str, payload: Value) { + capture + .lock() + .unwrap_or_else(|e| e.into_inner()) + .record(direction, payload); +} + +fn rpc_failure( + method: &'static str, + error: &agent_client_protocol::Error, + capture: &SharedCapture, + submitted: bool, +) -> Option { + if error.code == agent_client_protocol::schema::v1::ErrorCode::AuthRequired + || agent_client_protocol::is_incoming_transport_closed(error) + { + return None; + } + let mut message = Value::String(error.message.clone()); + capture + .lock() + .unwrap_or_else(|e| e.into_inner()) + .redact(&mut message, true); + Some(ClientError::Rpc { + method, + code: error.code.into(), + message: message.as_str().unwrap_or_default().to_owned(), + submitted, + }) +} + +fn io_failure() -> io::Error { + io::Error::other("ACP transport rejected a frame") +} + +struct PreparedLaunch { + cwd: PathBuf, + environment: BTreeMap, + secrets: SecretRedactor, +} + +fn validate_launch( + profile: &AcpProfile, + context: &LaunchContext, + limits: &ClientLimits, +) -> Result { + profile + .validate() + .map_err(|e| ClientError::InvalidConfiguration(e.to_string()))?; + if !(256..=16 * 1024 * 1024).contains(&limits.frame_bytes) + || !(1..=4096).contains(&limits.queued_frames) + || !(256..=64 * 1024 * 1024).contains(&limits.queued_bytes) + || !(1..=4096).contains(&limits.callback_frames) + || !(256..=64 * 1024 * 1024).contains(&limits.callback_bytes) + || limits.evidence_frames > 4096 + || limits.evidence_bytes > 16 * 1024 * 1024 + || limits.stderr_bytes > 1024 * 1024 + || [ + limits.setup_timeout, + limits.prompt_timeout, + limits.cancel_timeout, + limits.reap_timeout, + ] + .iter() + .any(Duration::is_zero) + { + return Err(ClientError::InvalidConfiguration( + "invalid resource limits".into(), + )); + } + let root = context + .workspace_root + .canonicalize() + .map_err(|_| ClientError::InvalidWorkspace)?; + let cwd = context + .issue_workspace + .canonicalize() + .map_err(|_| ClientError::InvalidWorkspace)?; + if !context.workspace_root.is_absolute() + || !context.issue_workspace.is_absolute() + || sanitize_workspace_key(&context.workspace_key) + .ok() + .as_deref() + != Some(&context.workspace_key) + || cwd != root.join(&context.workspace_key) + || cwd != context.issue_workspace + || !cwd.is_dir() + { + return Err(ClientError::InvalidWorkspace); + } + if has_environment_name_collision(profile.env_refs.keys().map(String::as_str)) { + return Err(ClientError::InvalidConfiguration( + "env_refs contains duplicate platform-equivalent targets".into(), + )); + } + let excluded = |key: &str| { + context + .excluded_environment + .iter() + .any(|e| environment_variable_names_equal(e, key)) + }; + let mut environment = context + .environment + .iter() + .filter(|(key, _)| { + !excluded(key) + && (!profile + .env_refs + .values() + .any(|source| environment_variable_names_equal(source, key)) + || profile + .env_refs + .keys() + .any(|target| environment_variable_names_equal(target, key))) + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(); + let mut secrets = Vec::new(); + for (target, source) in &profile.env_refs { + if excluded(target) || excluded(source) { + return Err(ClientError::InvalidConfiguration( + "env_refs cannot expose excluded checkout credentials".into(), + )); + } + let value = context + .environment + .iter() + .find(|(key, _)| environment_variable_names_equal(key, source)) + .map(|(_, value)| value) + .filter(|v| !v.is_empty() && !v.contains('\0')) + .ok_or_else(|| { + ClientError::InvalidConfiguration( + "env_refs references a missing or invalid variable".into(), + ) + })?; + secrets.push(value.clone()); + insert_environment_value(&mut environment, target.clone(), value.clone()); + } + for (key, value) in &context.environment { + if !value.is_empty() + && (excluded(key) + || runtime_field_is_sensitive(key) + || ["TOKEN", "SECRET", "PASSWORD", "KEY", "CREDENTIAL"] + .iter() + .any(|s| key.to_ascii_uppercase().contains(s))) + { + secrets.push(value.clone()); + } + } + if environment + .iter() + .any(|(k, v)| k.is_empty() || k.contains(['=', '\0']) || v.contains('\0')) + { + return Err(ClientError::InvalidConfiguration( + "invalid launch environment".into(), + )); + } + let secrets = SecretRedactor::new(secrets)?; + if secrets.contains_secret(&profile.command) + || profile.args.iter().any(|arg| secrets.contains_secret(arg)) + { + return Err(ClientError::InvalidConfiguration( + "credentials cannot appear in argv".into(), + )); + } + Ok(PreparedLaunch { + cwd, + environment, + secrets, + }) +} + +/// Run a fresh session through setup, one prompt, cancellation, and supervised teardown. +/// The bounded update channel is optional. A full or closed supplied channel fails the run +/// visibly, so callers must drain it concurrently. The launch environment is explicit: +/// callers supply scoped memory grants and checkout credential exclusions before this call. +pub async fn run_turn( + profile: &AcpProfile, + context: LaunchContext, + prompt: String, + cancellation: CancellationToken, + updates: Option>, + limits: ClientLimits, +) -> Result { + let PreparedLaunch { + cwd, + environment, + secrets, + } = validate_launch(profile, &context, &limits)?; + if cancellation.is_cancelled() { + return Err(ClientError::CancelledBeforePrompt); + } + let capture_state = Arc::new(Mutex::new(Capture { + frames: Vec::new(), + sequence: 0, + truncated: false, + max: limits.evidence_frames, + bytes: 0, + max_bytes: limits.evidence_bytes, + secrets, + })); + let mut command = Command::new(&profile.command); + command + .args(&profile.args) + .current_dir(&cwd) + .env_clear() + .envs(environment) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(not(windows))] + configure_process_group(&mut command); + #[cfg(not(windows))] + let mut child = command.spawn().map_err(|e| ClientError::Launch(e.kind()))?; + #[cfg(windows)] + let mut child = + windows_process::WindowsChild::spawn(command).map_err(|e| ClientError::Launch(e.kind()))?; + #[cfg(not(windows))] + let process_id = child.id(); + #[cfg(unix)] + let mut group_guard = crate::opensymphony_workspace::ProcessGroupGuard::new(process_id); + let stdin = child.stdin.take().ok_or(ClientError::Teardown)?; + let stdout = child.stdout.take().ok_or(ClientError::Teardown)?; + let stderr = child.stderr.take().ok_or(ClientError::Teardown)?; + let queued = Arc::new(Mutex::new(QueuedInput::default())); + let callback_output = Arc::new(Mutex::new(CallbackOutput::default())); + let resource_failure = Arc::new(AtomicBool::new(false)); + let submitted = Arc::new(AtomicBool::new(false)); + let fatal = CancellationToken::new(); + let input = FramedRead::new(stdout, LinesCodec::new_with_max_length(limits.frame_bytes)); + let incoming = input.map({ + let queued = queued.clone(); + let capture_state = capture_state.clone(); + let resource_failure = resource_failure.clone(); + move |line| { + let line = line.map_err(|_| { + resource_failure.store(true, Ordering::Release); + io_failure() + })?; + let value: Value = serde_json::from_str(&line).map_err(|_| io_failure())?; + // ACP uses one JSON-RPC object per LF frame; batches are outside this transport contract. + if !value.is_object() || value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(io_failure()); + } + if !queued.lock().unwrap_or_else(|e| e.into_inner()).admit( + line.len(), + limits.queued_frames, + limits.queued_bytes, + ) { + resource_failure.store(true, Ordering::Release); + return Err(io_failure()); + } + capture(&capture_state, "incoming", value); + Ok(line) + } + }); + let output = FramedWrite::new(stdin, LinesCodec::new_with_max_length(limits.frame_bytes)); + // The SDK awaits SinkExt::send for each frame. Keep the reservation until that + // send flushes the framed writer; preprocessing or start_send is too early. + let outgoing = futures_util::sink::unfold(output, { + let callback_output = callback_output.clone(); + let capture_state = capture_state.clone(); + let resource_failure = resource_failure.clone(); + let submitted = submitted.clone(); + move |mut output, line: String| { + let callback_output = callback_output.clone(); + let capture_state = capture_state.clone(); + let resource_failure = resource_failure.clone(); + let submitted = submitted.clone(); + async move { + if line.len() > limits.frame_bytes { + resource_failure.store(true, Ordering::Release); + return Err(io_failure()); + } + let value: Value = serde_json::from_str(&line).map_err(|_| io_failure())?; + let is_response = value.get("method").is_none(); + if is_response + && !callback_output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .matches_front(&line) + { + // Invalid incoming RPC envelopes can provoke SDK-generated errors. + // They must not bypass callback admission or release another charge. + return Err(io_failure()); + } + if value.get("method").and_then(Value::as_str) == Some("session/prompt") { + submitted.store(true, Ordering::Release); + } + capture(&capture_state, "outgoing", value); + output.send(line).await.map_err(|_| io_failure())?; + if is_response { + callback_output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .flushed(); + } + Ok(output) + } + } + }); + let active_session = Arc::new(Mutex::new(None::)); + let client = Client.builder().on_receive_dispatch( + { + let active_session = active_session.clone(); + let callback_output = callback_output.clone(); + let limits = limits.clone(); + let resource_failure = resource_failure.clone(); + let fatal = fatal.clone(); + let redactor = capture_state.clone(); + async move |message: Dispatch, _cx| { + queued + .lock() + .unwrap_or_else(|e| e.into_inner()) + .dispatched(); + match message { + Dispatch::Request(request, responder) => { + let response = if request.method == "session/request_permission" { + match serde_json::from_value::(request.params) + { + Ok(request) + if active_session + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_deref() + == Some(request.session_id.0.as_ref()) => + { + // No operator policy is advertised in this slice. + serde_json::to_value(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )) + .map_err(agent_client_protocol::Error::from) + } + _ => Err(agent_client_protocol::Error::invalid_params()), + } + } else { + Err(agent_client_protocol::Error::method_not_found()) + }; + let frame = serde_json::to_string(&RawJsonRpcMessage::response( + responder.id().clone(), + response.clone(), + ))?; + if !callback_output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .admit(frame, &limits) + { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + // Individual-request responders send nothing on drop. Batches + // are rejected at ingress, so saturation cannot enqueue a reply. + return Err(agent_client_protocol::Error::internal_error()); + } + responder.respond_with_result(response) + } + Dispatch::Notification(notification) => { + if notification.method == "session/update" { + let id = notification.params.get("sessionId").and_then(Value::as_str); + let update = notification.params.get("update").filter(|update| { + update.is_object() + && update + .get("sessionUpdate") + .and_then(Value::as_str) + .is_some() + }); + let (Some(id), Some(update)) = (id, update) else { + fatal.cancel(); + return Err(agent_client_protocol::Error::invalid_params()); + }; + if active_session + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_deref() + != Some(id) + { + fatal.cancel(); + return Err(agent_client_protocol::Error::invalid_params()); + } + if let Some(tx) = &updates { + let mut update = update.clone(); + redactor + .lock() + .unwrap_or_else(|e| e.into_inner()) + .redact(&mut update, false); + tx.try_send(SessionUpdate { + session_id: id.into(), + update, + }) + .map_err(|_| { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + agent_client_protocol::Error::internal_error() + })?; + } + } + Ok(()) + } + Dispatch::Response(result, router) => router.route_with_result(result), + } + } + }, + agent_client_protocol::on_receive_dispatch!(), + ); + let mut phase_error = None; + let run = client + .connect_with(Lines::new(outgoing, incoming), async |connection| { + let setup = async { + let initialization = connection + .send_request(InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await.inspect_err(|error| { + phase_error = rpc_failure("initialize", error, &capture_state, false); + })?; + if initialization.protocol_version != ProtocolVersion::V1 { + phase_error = Some(ClientError::Setup("agent did not negotiate ACP v1".into())); + return Err(agent_client_protocol::Error::internal_error()); + } + for capability in &profile.required_capabilities { + let caps = &initialization.agent_capabilities.prompt_capabilities; + let present = match capability.as_str() { + "prompt.image" => caps.image, + "prompt.audio" => caps.audio, + "prompt.embedded_context" => caps.embedded_context, + _ => false, + }; + if !present { + phase_error = Some(ClientError::Setup(format!("required agent capability `{capability}` is unavailable"))); + return Err(agent_client_protocol::Error::internal_error()); + } + } + if let Some(auth) = &profile.auth { + if !initialization.auth_methods.iter().any(|m| { + matches!(m, AuthMethod::Agent(_)) && m.id().0.as_ref() == auth.method_id + }) { + phase_error = Some(ClientError::Setup( + "configured authentication method is unavailable or requires an unsupported interactive flow".into(), + )); + return Err(agent_client_protocol::Error::internal_error()); + } + connection.send_request(AuthenticateRequest::new(auth.method_id.clone())) + .block_task().await.inspect_err(|error| { + phase_error = rpc_failure("authenticate", error, &capture_state, false); + })?; + } + let (session_tx, session_rx) = oneshot::channel(); + let active_session = active_session.clone(); + // Register before yielding. The ordered callback binds ownership before + // the SDK releases any adjacent session/update or permission request. + connection.send_request(NewSessionRequest::new(cwd)) + .on_receiving_result(async move |result| { + if let Ok(session) = &result { + *active_session.lock().unwrap_or_else(|e| e.into_inner()) = + Some(session.session_id.0.to_string()); + } + let _ = session_tx.send(result); + Ok(()) + })?; + let session = session_rx.await + .map_err(|_| agent_client_protocol::Error::internal_error())? + .inspect_err(|error| { + phase_error = rpc_failure("session/new", error, &capture_state, false); + })?; + Ok((initialization, session.session_id)) + }; + let setup_result = tokio::select! { + _ = cancellation.cancelled() => { + phase_error = Some(ClientError::CancelledBeforePrompt); + return Err(agent_client_protocol::Error::internal_error()); + }, + result = tokio::time::timeout(limits.setup_timeout, setup) => result, + }; + let (initialization, session_id) = match setup_result { + Ok(result) => result?, + Err(_) => { + phase_error = Some(ClientError::SetupTimeout); + return Err(agent_client_protocol::Error::internal_error()); + } + }; + if cancellation.is_cancelled() { + phase_error = Some(ClientError::CancelledBeforePrompt); + return Err(agent_client_protocol::Error::internal_error()); + } + let request = PromptRequest::new( + session_id.clone(), vec![ContentBlock::Text(TextContent::new(prompt))], + ); + // Raw SDK response decoding preserves future stop reasons; request serialization stays typed. + let request = UntypedMessage::new("session/prompt", request)?; + let response = connection.send_request(request).block_task(); + tokio::pin!(response); + let mut cancellation_requested = false; + let result = tokio::select! { + result = &mut response => result, + _ = cancellation.cancelled() => { + cancellation_requested = true; + connection.send_notification(CancelNotification::new(session_id.clone()))?; + match tokio::time::timeout(limits.cancel_timeout, &mut response).await { + Ok(result) => result, + Err(_) => { + phase_error = Some(ClientError::CancelTimeout); + return Err(agent_client_protocol::Error::internal_error()); + } + } + }, + _ = tokio::time::sleep(limits.prompt_timeout) => { + phase_error = Some(ClientError::PromptTimeout); + return Err(agent_client_protocol::Error::internal_error()); + } + }.inspect_err(|error| { + phase_error = rpc_failure("session/prompt", error, &capture_state, submitted.load(Ordering::Acquire)); + })?; + let stop_reason = result.get("stopReason").and_then(Value::as_str) + .ok_or_else(agent_client_protocol::Error::invalid_params)?.to_owned(); + Ok(TurnReport { + cancellation_acknowledged: cancellation_requested && stop_reason == "cancelled", + cancellation_requested, stop_reason, session_id: session_id.0.to_string(), initialization, + }) + }) + .with_subscriber(tracing::subscriber::NoSubscriber::default()); + let stderr_drain = drain_stderr(stderr, limits.stderr_bytes); + tokio::pin!(stderr_drain); + let mut stderr_result = None; + let result = { + tokio::pin!(run); + loop { + tokio::select! { + _ = fatal.cancelled() => break Err(agent_client_protocol::Error::internal_error()), + result = &mut run => break result, + stderr = &mut stderr_drain, if stderr_result.is_none() => { + stderr_result = Some(stderr); + } + } + } + }; + let mut outcome = result.map_err(|error| { + phase_error.unwrap_or_else(|| { + if resource_failure.load(Ordering::Acquire) { + ClientError::ResourceLimit { + submitted: submitted.load(Ordering::Acquire), + } + } else if error.code == agent_client_protocol::schema::v1::ErrorCode::AuthRequired { + ClientError::AuthenticationRequired { + submitted: submitted.load(Ordering::Acquire), + } + } else if agent_client_protocol::is_incoming_transport_closed(&error) { + ClientError::Disconnected { + submitted: submitted.load(Ordering::Acquire), + } + } else { + ClientError::Protocol { + submitted: submitted.load(Ordering::Acquire), + } + } + }) + }); + if fatal.is_cancelled() && outcome.is_ok() { + outcome = Err(ClientError::Protocol { + submitted: submitted.load(Ordering::Acquire), + }); + } + if resource_failure.load(Ordering::Acquire) { + outcome = Err(ClientError::ResourceLimit { + submitted: submitted.load(Ordering::Acquire), + }); + } + let reap_deadline = tokio::time::Instant::now() + limits.reap_timeout; + #[cfg(windows)] + let stopped_result = child.start_kill(); + #[cfg(not(windows))] + let stopped_result = tokio::time::timeout_at( + reap_deadline, + terminate_process_tree(&mut child, process_id), + ) + .await + .unwrap_or_else(|_| Err(io::ErrorKind::TimedOut.into())); + let reaped_result = tokio::time::timeout_at(reap_deadline, child.wait()).await; + let process_reaped = matches!(reaped_result, Ok(Ok(_))); + #[cfg(unix)] + if process_reaped && stopped_result.is_ok() { + group_guard.disarm(); + } + if !process_reaped + || stopped_result + .as_ref() + .is_err_and(|e| e.kind() == io::ErrorKind::TimedOut) + { + outcome = Err(ClientError::Teardown); + } + let stderr = match stderr_result { + Some(s) => s, + None => tokio::time::timeout(limits.reap_timeout, &mut stderr_drain) + .await + .unwrap_or_else(|_| "[stderr drain deadline exceeded]".into()), + }; + let mut state = capture_state.lock().unwrap_or_else(|e| e.into_inner()); + let mut stderr = json!(stderr); + state.redact(&mut stderr, true); + Ok(RunResult { + outcome, + evidence: std::mem::take(&mut state.frames), + evidence_truncated: state.truncated, + stderr: stderr.as_str().unwrap_or_default().into(), + process_reaped, + process_tree_signal_error: stopped_result.err().map(|e| e.kind()), + }) +} + +async fn drain_stderr(stderr: tokio::process::ChildStderr, max: usize) -> String { + let mut reader = BufReader::new(stderr); + let mut bytes = Vec::new(); + let mut buffer = [0; 4096]; + let mut overflow = false; + loop { + match reader.read(&mut buffer).await { + Ok(0) => break, + Ok(count) => { + if bytes.len() + count <= max && !overflow { + bytes.extend_from_slice(&buffer[..count]); + } else { + overflow = true; + bytes.clear(); + } + } + Err(_) => return "[stderr read failed]".into(), + } + } + if overflow { + "[stderr capture limit exceeded]".into() + } else { + String::from_utf8_lossy(&bytes).into_owned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secret_matching_handles_many_patterns_overlaps_and_large_input() { + let mut secrets = (0..128) + .map(|i| format!("credential-{i:03}-value")) + .collect::>(); + secrets.extend(["overlap".into(), "overlapping".into(), "[redacted]".into()]); + let redactor = SecretRedactor::new(secrets).expect("bounded matcher"); + assert_eq!( + redactor.redact("overlapping overlap credential-127-value"), + "[redacted] [redacted] [redacted]" + ); + let input = format!("{}credential-127-value", "x".repeat(16 * 1024 * 1024)); + let result = redactor.redact(&input); + assert!(result.ends_with("[redacted]")); + assert_eq!(result.len(), 16 * 1024 * 1024 + "[redacted]".len()); + assert!(SecretRedactor::new(vec!["x".repeat(1024 * 1024 + 1)]).is_err()); + assert!(SecretRedactor::new((0..1025).map(|i| format!("secret-{i}")).collect()).is_err()); + } + + #[test] + fn queued_input_enforces_both_budgets_and_releases_dispatched_bytes() { + let mut queue = QueuedInput::default(); + assert!(queue.admit(3, 4, 8)); + assert!(queue.admit(5, 4, 8)); + assert!(!queue.admit(1, 4, 8)); + assert_eq!(queue.bytes, 8); + assert_eq!(queue.frame_sizes.len(), 2); + queue.dispatched(); + assert_eq!(queue.bytes, 5); + assert!(!queue.admit(4, 4, 8)); + assert!(queue.admit(3, 4, 8)); + queue.dispatched(); + queue.dispatched(); + assert_eq!(queue.bytes, 0); + assert!(!queue.admit(9, 4, 8)); + assert!(queue.admit(1, 1, 8)); + assert!(!queue.admit(1, 1, 8)); + } + + #[test] + fn callback_output_budget_counts_encoded_frames_until_flush() { + let frame = serde_json::to_string(&RawJsonRpcMessage::response( + serde_json::from_value(json!("\"\\".repeat(128))).expect("id"), + Err(agent_client_protocol::Error::method_not_found()), + )) + .expect("response frame"); + for budget in [ + frame.len(), + frame.len() + 1, + 2 * (frame.len() + 1) - 1, + 2 * (frame.len() + 1), + ] { + let limits = ClientLimits { + callback_bytes: budget, + ..ClientLimits::default() + }; + let mut output = CallbackOutput::default(); + let expected = budget / (frame.len() + 1); + assert_eq!(output.admit(frame.clone(), &limits), expected >= 1); + assert_eq!(output.admit(frame.clone(), &limits), expected >= 2); + assert_eq!(output.bytes, expected * (frame.len() + 1)); + assert!(!output.matches_front("unreserved response")); + if expected > 0 { + assert!(output.matches_front(&frame)); + output.flushed(); + assert!(output.admit(frame.clone(), &limits)); + } + } + let mut output = CallbackOutput::default(); + let limits = ClientLimits { + callback_frames: 1, + ..ClientLimits::default() + }; + assert!(output.admit(frame.clone(), &limits)); + assert!(!output.admit(frame.clone(), &limits)); + output.flushed(); + assert_eq!(output.bytes, 0); + assert!(!output.admit( + frame.clone(), + &ClientLimits { + frame_bytes: frame.len() - 1, + ..limits + } + )); + } + + #[test] + fn evidence_byte_budget_includes_metadata_and_accepts_exact_boundary() { + let frame = SourceFrame { + sequence: 1, + direction: "incoming".into(), + observed_at: chrono::DateTime::from_timestamp(0, 0).expect("epoch"), + payload: json!({"jsonrpc": "2.0", "method": "_future/notice", "params": ["a", "b"]}), + }; + let size = serde_json::to_vec(&frame).expect("frame JSON").len(); + for budget in [0, size - 1, size, size * 2 - 1, size * 2] { + let mut capture = Capture { + frames: Vec::new(), + sequence: 0, + truncated: false, + max: 256, + bytes: 0, + max_bytes: budget, + secrets: SecretRedactor::new(Vec::new()).expect("empty matcher"), + }; + capture.retain_frame(frame.clone()); + capture.retain_frame(frame.clone()); + assert_eq!(capture.frames.len(), budget / size); + assert_eq!(capture.bytes, size * (budget / size)); + assert_eq!(capture.truncated, budget < size * 2); + } + } +} diff --git a/crates/opensymphony-acp/src/windows_process.rs b/crates/opensymphony-acp/src/windows_process.rs new file mode 100644 index 000000000..5eaf4a69d --- /dev/null +++ b/crates/opensymphony-acp/src/windows_process.rs @@ -0,0 +1,151 @@ +//! Job ownership is established while the child is suspended, before it can spawn descendants. +use std::{io, process::ExitStatus, time::Duration}; + +use process_wrap::tokio::{ChildWrapper, CommandWrap, JobObject, KillOnDrop}; +use tokio::process::{ChildStderr, ChildStdin, ChildStdout, Command}; + +pub(super) struct WindowsChild { + inner: Box, + pub(super) stdin: Option, + pub(super) stdout: Option, + pub(super) stderr: Option, +} + +impl WindowsChild { + pub(super) fn spawn(command: Command) -> io::Result { + let mut inner = CommandWrap::from(command) + .wrap(KillOnDrop) + .wrap(JobObject) + .spawn()?; + Ok(Self { + stdin: inner.stdin().take(), + stdout: inner.stdout().take(), + stderr: inner.stderr().take(), + inner, + }) + } + + #[cfg(test)] + pub(super) fn id(&self) -> Option { + self.inner.id() + } + + pub(super) fn start_kill(&mut self) -> io::Result<()> { + // Direct TerminateJobObject: no external taskkill process or unbounded wait. + self.inner.start_kill() + } + + pub(super) async fn wait(&mut self) -> io::Result { + // Avoid the wrapper's blocking job-completion waiter: timeout/drop must + // release job ownership immediately, including when the parent has exited. + loop { + if let Some(status) = self.inner.try_wait()? { + return Ok(status); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{path::Path, process::Stdio}; + use tokio::time::{sleep, timeout}; + + async fn spawn_tree(root: &Path, parent_exits: bool) -> (WindowsChild, u32) { + let mut command = Command::new("python"); + command + .arg("-c") + .arg("import pathlib, subprocess, sys, time; child=subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)']); pathlib.Path('descendant.pid').write_text(str(child.pid)); time.sleep(0 if sys.argv[1] == 'exit' else 120)") + .arg(if parent_exits { "exit" } else { "hang" }) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = WindowsChild::spawn(command).expect("job spawn"); + assert!(child.id().is_some()); + assert!(child.stdin.is_some() && child.stdout.is_some() && child.stderr.is_some()); + let pid = timeout(Duration::from_secs(10), async { + loop { + if let Ok(pid) = std::fs::read_to_string(root.join("descendant.pid")) + && let Ok(pid) = pid.parse::() + { + break pid; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("descendant started"); + (child, pid) + } + + async fn assert_dead(pid: u32) { + let mut probe = Command::new("powershell.exe"); + probe + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(format!( + "for ($i=0; $i -lt 200; $i++) {{ if (-not (Get-Process -Id {pid} -ErrorAction SilentlyContinue)) {{ exit 0 }}; Start-Sleep -Milliseconds 25 }}; exit 1" + )) + .kill_on_drop(true); + let status = timeout(Duration::from_secs(10), probe.status()) + .await + .expect("bounded probe") + .expect("process probe"); + assert!(status.success(), "descendant {pid} survived"); + } + + #[tokio::test] + async fn windows_job_teardown_terminates_descendants() { + let root = tempfile::tempdir().expect("temp"); + let (mut child, pid) = spawn_tree(root.path(), false).await; + child.start_kill().expect("terminate job"); + timeout(Duration::from_secs(5), child.wait()) + .await + .expect("reap deadline") + .expect("reap"); + drop(child); + assert_dead(pid).await; + } + + #[tokio::test] + async fn windows_job_survives_parent_exit_until_owner_drops() { + let root = tempfile::tempdir().expect("temp"); + let (mut child, pid) = spawn_tree(root.path(), true).await; + timeout(Duration::from_secs(5), child.wait()) + .await + .expect("parent deadline") + .expect("parent reap"); + drop(child); + assert_dead(pid).await; + } + + #[tokio::test] + async fn windows_job_wait_deadline_remains_cancellation_safe() { + let root = tempfile::tempdir().expect("temp"); + let (mut child, pid) = spawn_tree(root.path(), false).await; + assert!( + timeout(Duration::from_millis(50), child.wait()) + .await + .is_err() + ); + drop(child); + assert_dead(pid).await; + } + + #[tokio::test] + async fn windows_job_dropped_future_terminates_descendants() { + let root = tempfile::tempdir().expect("temp"); + let (mut child, pid) = spawn_tree(root.path(), false).await; + let (started, active) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + started.send(()).expect("notify owner"); + child.wait().await + }); + active.await.expect("wait future is active"); + task.abort(); + assert!(task.await.expect_err("aborted future").is_cancelled()); + assert_dead(pid).await; + } +} diff --git a/crates/opensymphony-cli/src/migration.rs b/crates/opensymphony-cli/src/migration.rs index 9275e5fe8..058d3498c 100644 --- a/crates/opensymphony-cli/src/migration.rs +++ b/crates/opensymphony-cli/src/migration.rs @@ -1976,6 +1976,9 @@ fn build_report( if front_matter.routing.harness.is_some() { recognized.push("routing"); } + if !front_matter.acp.profiles.is_empty() { + recognized.push("acp"); + } if front_matter.openhands.transport.base_url.is_some() { recognized.push("openhands"); } @@ -2140,6 +2143,7 @@ fn generate_central_config(source: &SourceContext) -> Result Result bool { || front_matter.agent != Default::default() || front_matter.openhands != Default::default() || front_matter.routing != Default::default() + || front_matter.acp != Default::default() } fn workflow_body(source: &SourceContext) -> Result, MigrationError> { @@ -4862,6 +4868,49 @@ mod tests { assert!(!generated.contains("path: AGENTS.md")); } + #[test] + fn migration_preserves_acp_profiles_and_selector_in_central_configuration() { + let root = tempfile::tempdir().expect("migration root"); + let target_repo = root.path().join("repo"); + fs::create_dir_all(&target_repo).expect("repository"); + let workflow_source = "---\ntracker:\n kind: linear\n project_slug: project\nrouting:\n harness: acp\n harness_profile: local\nacp:\n profiles:\n local:\n command: agent\n args: [--acp]\n env_refs: {AGENT_TOKEN: ACP_TOKEN_SOURCE}\n---\nTarget branch: develop\n".to_owned(); + let workflow = WorkflowDefinition::parse(&workflow_source).expect("parse"); + let expected = workflow.front_matter.acp.clone(); + let source = SourceContext { + source_config: target_repo.join("config.yaml"), + config_source: String::new(), + source_config_present: false, + workflow_path: target_repo.join("WORKFLOW.md"), + workflow_source, + target_repo, + workflow, + config: LegacyConfigProbe { + target_repo: None, + control_plane: LegacyControlPlaneProbe::default(), + openhands: LegacyOpenHandsProbe::default(), + memory: LegacyMemoryProbe::default(), + }, + remote: "git@github.com:example/repo.git".to_owned(), + }; + let generated = generate_central_config(&source).expect("generate"); + let generated: serde_yaml::Value = serde_yaml::from_str(&generated).expect("central YAML"); + let acp: crate::opensymphony_workflow::AcpConfig = + serde_yaml::from_value(generated["acp"].clone()).expect("profiles"); + assert_eq!(acp, expected); + acp.validate_selection( + generated["routing"]["harness"].as_str().expect("harness"), + generated["routing"]["harness_profile"].as_str(), + false, + ) + .expect("migrated selection remains valid"); + let rewritten = String::from_utf8(workflow_body(&source).expect("body")).expect("UTF-8"); + assert!(rewritten.contains("Target branch: develop")); + assert!(!rewritten.contains("ACP_TOKEN_SOURCE")); + assert!(workflow_has_orchestration_front_matter( + "---\nacp:\n profiles:\n unused:\n command: agent\n---\nPrompt\n" + )); + } + #[test] fn migration_preserves_linear_project_id_separately_from_slug() { let target_repo = PathBuf::from("/repo"); diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index a1bdb6b41..ec8fa42ee 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -23,6 +23,7 @@ use crate::opensymphony_workflow::{ }; use crate::opensymphony_workspace::{ CheckoutRepository, SSH_AUTH_SOCK_ENV, environment_variable_names_equal, + normalize_secret_field_name, runtime_field_is_sensitive, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -57,6 +58,8 @@ struct RunConfigFile { #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct CentralConfigFile { + #[serde(default)] + acp: crate::opensymphony_workflow::AcpConfig, #[serde(default)] schema_version: u32, instance: CentralInstanceFile, @@ -101,6 +104,8 @@ struct CentralInstanceFile { #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct CentralRoutingFile { + #[serde(default)] + harness_profile: Option, mode: String, #[serde(default)] active_project_set: Option, @@ -392,6 +397,11 @@ pub enum CentralConfigError { EmptyField { field: &'static str }, #[error("central config reference `{field}` does not resolve")] InvalidReference { field: String }, + #[error("invalid central ACP configuration: {source}")] + InvalidAcp { + #[source] + source: crate::opensymphony_workflow::WorkflowConfigError, + }, #[error("central config aliases must be unique: `{alias}`")] DuplicateAlias { alias: String }, #[error( @@ -740,6 +750,7 @@ pub(crate) fn select_config_path(cwd: &Path, explicit: Option<&Path>) -> Option< } const CENTRAL_CONFIG_KEYS: &[&str] = &[ + "acp", "instance", "routing", "tracker_profiles", @@ -1428,6 +1439,12 @@ fn resolve_central_config( )?; let repository_checkouts = build_repository_checkouts(&config)?; let memory_sources = resolve_memory_sources(&config, config_root, &repository_routing)?; + // Selection depends on harness/model environment overrides. The workflow + // resolver validates it after those values resolve; central load checks shape. + config + .acp + .validate_profiles() + .map_err(|source| CentralConfigError::InvalidAcp { source })?; let workflow_front_matter = central_workflow_front_matter(&config, Some(&workspace_root))?; let repository_instruction_path = legacy_repository_instruction_path; Ok(ResolvedCentralConfig { @@ -1501,6 +1518,18 @@ fn reject_checkout_credential_env_reuse( } } + for (profile_id, profile) in &config.acp.profiles { + if profile.env_refs.iter().any(|(target, source)| { + checkout_variables.iter().any(|checkout| { + environment_variable_names_equal(checkout, target) + || environment_variable_names_equal(checkout, source) + }) + }) { + return Err(CentralConfigError::InvalidReference { + field: format!("acp.profiles.{profile_id}.env_refs"), + }); + } + } let mut non_checkout_variables = BTreeMap::new(); if let Some(variable) = config.openhands.transport_session_api_key_env.as_deref() { non_checkout_variables.insert( @@ -2017,7 +2046,9 @@ fn central_workflow_front_matter( }) .transpose()?, }, + acp: config.acp.clone(), routing: RoutingFrontMatter { + harness_profile: config.routing.harness_profile.clone(), harness: config.routing.harness.clone(), model: config.routing.model.clone(), model_profile: config.routing.model_profile.clone(), @@ -2436,7 +2467,7 @@ fn openhands_yaml_value_has_literal_secret(value: &serde_yaml::Value) -> bool { normalize_secret_field_name(key) == "command" && openhands_command_has_literal_secret(value) }); - let secret_name = key.as_str().is_some_and(openhands_secret_field_name) + let secret_name = key.as_str().is_some_and(runtime_field_is_sensitive) && match value.as_str() { Some(value) => !is_central_credential_reference(value), None => !value.is_null(), @@ -2499,67 +2530,6 @@ fn validate_openhands_env_references( Ok(()) } -fn openhands_secret_field_name(name: &str) -> bool { - // OpenHands emits some identity headers with hyphens (for example, - // `chatgpt-account-id`) even though most serialized config uses - // underscore-separated keys. Normalize the separator before applying the - // secret-shaped field rules so both spellings fail closed. - let name = normalize_secret_field_name(name); - [ - "access_token", - "api_key", - "apikey", - "authorization", - "access_key", - "accesskey", - "account_id", - "accountid", - "account_identifier", - "account_identity", - "chatgpt_account_id", - "credential", - "password", - "pat", - "secret", - "token", - ] - .iter() - .any(|part| name == *part || name.ends_with(&format!("_{part}"))) -} - -fn normalize_secret_field_name(name: &str) -> String { - let characters = name.chars().collect::>(); - let mut normalized = String::with_capacity(name.len()); - for (index, character) in characters.iter().copied().enumerate() { - if character == '-' { - if !normalized.ends_with('_') { - normalized.push('_'); - } - continue; - } - if character.is_ascii_uppercase() { - let previous_is_lower_or_digit = characters - .get(index.wrapping_sub(1)) - .is_some_and(|previous| previous.is_ascii_lowercase() || previous.is_ascii_digit()); - let previous_is_acronym_boundary = characters - .get(index.wrapping_sub(1)) - .is_some_and(|previous| previous.is_ascii_uppercase()) - && characters - .get(index + 1) - .is_some_and(|next| next.is_ascii_lowercase()); - if (previous_is_lower_or_digit || previous_is_acronym_boundary) - && !normalized.ends_with('_') - { - normalized.push('_'); - } - normalized.push(character.to_ascii_lowercase()); - } else { - normalized.push(character.to_ascii_lowercase()); - } - } - normalized -} - fn validate_active_repository_aliases( config: &CentralConfigFile, active_repositories: &BTreeSet, @@ -2972,6 +2942,115 @@ scheduler: ) } + #[test] + fn acp_central_profiles_remain_authoritative_and_validate_references() { + let repo = tempfile::tempdir().expect("temp root"); + std::fs::write( + repo.path().join("integration.md"), + "Integration instructions", + ) + .expect("instructions"); + let source = central_fixture(repo.path()).replace( + " mode: project_set", + " harness: acp\n harness_profile: local\n mode: project_set", + ) + "acp:\n profiles:\n local:\n command: python3\n env_refs: {AGENT_TOKEN: ACP_TEST_TOKEN}\n"; + let resolved = resolve_central_config(&repo.path().join("config.yaml"), &source) + .expect("ACP central config"); + assert_eq!( + resolved + .workflow_front_matter + .routing + .harness_profile + .as_deref(), + Some("local") + ); + let local = WorkflowFrontMatter::default(); + let merged = merge_repository_local_front_matter(resolved.workflow_front_matter, &local); + assert_eq!( + merged.acp.profiles["local"].env_refs["AGENT_TOKEN"], + "ACP_TEST_TOKEN" + ); + let env = BTreeMap::from([("LINEAR_API_KEY".into(), "test-key".into())]); + let absent = resolve_central_config( + &repo.path().join("config.yaml"), + &source.replace("harness_profile: local", "harness_profile: absent"), + ) + .expect("selection is resolved with workflow environment"); + let workflow = WorkflowDefinition { + front_matter: absent.workflow_front_matter, + prompt_template: "Prompt".into(), + }; + assert!( + workflow + .resolve(repo.path(), &env) + .expect_err("unknown profile") + .to_string() + .contains("routing.harness_profile") + ); + for (replacement, expected) in [ + ( + "command: python3\n extensions: [unknown]", + "extensions must be empty", + ), + ( + "command: python3\n protocol_versions: [2]", + "protocol_versions", + ), + ( + "command: python3\n auth: {method_id: ''}", + "auth.method_id", + ), + ] { + let error = resolve_central_config( + &repo.path().join("config.yaml"), + &source.replace("command: python3", replacement), + ) + .expect_err("invalid ACP shape") + .to_string(); + assert!( + error.contains("profile `local`") && error.contains(expected), + "{error}" + ); + } + // Central loading preserves the selection until workflow environment + // overrides are available, including configurable override variable names. + for raw_harness in [ + "harness: openhands_agent_server", + "harness: $ACP_HARNESS", + "", + ] { + let central = resolve_central_config( + &repo.path().join("config.yaml"), + &source.replace("harness: acp", raw_harness), + ) + .expect("central config"); + let workflow = WorkflowDefinition { + front_matter: central.workflow_front_matter, + prompt_template: "Prompt".into(), + }; + let mut overrides = env.clone(); + overrides.insert("TEST_HARNESS".into(), "acp".into()); + overrides.insert("ACP_HARNESS".into(), "openhands_agent_server".into()); + assert_eq!( + workflow + .resolve(repo.path(), &overrides) + .expect("resolved ACP override") + .config + .routing + .harness, + "acp" + ); + overrides.insert("TEST_MODEL".into(), "unsupported-model".into()); + assert!( + workflow + .resolve(repo.path(), &overrides) + .expect_err("resolved model override") + .to_string() + .contains("ACP model overrides") + ); + } + } + #[test] fn central_config_resolves_relative_config_path_once() { let source = r#"schema_version: 1 @@ -3648,6 +3727,30 @@ scheduler: assert_eq!(resolved.retry_max_attempts, Some(0)); } + #[tokio::test] + async fn acp_only_central_config_fails_closed_before_legacy_resolution() { + let root = tempfile::tempdir().expect("config root"); + let path = root.path().join("config.yaml"); + for raw in [ + "schema_version: 1\nacp: {}\n", + "acp:\n profiles: [broken\n", + ] { + std::fs::write(&path, raw).expect("write config"); + assert!(looks_like_central_config(raw)); + let error = resolve_runtime_config(&RunArgs { + config: Some(path.clone()), + dry_run: true, + }) + .await + .err() + .expect("incomplete central config must fail"); + assert!( + matches!(error, RunCommandError::CentralConfig(_)), + "{error}" + ); + } + } + #[test] fn central_config_discriminator_requires_instance_and_routing_mode() { // Legacy config files may carry schema metadata without opting into diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index fbcb0fa0c..7de329f39 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -10178,6 +10178,7 @@ mod tests { tracker_project_id_slug_fallbacks: Vec::new(), tracker_project_slugs: Vec::new(), routing: RoutingConfig { + harness_profile: None, harness: "rust_native".to_owned(), model: None, model_profile: None, diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 214d72df2..170223707 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -64,6 +64,7 @@ fn scheduler_config() -> SchedulerConfig { tracker_project_id_slug_fallbacks: Vec::new(), tracker_project_slugs: Vec::new(), routing: RoutingConfig { + harness_profile: None, harness: "openhands_agent_server".into(), model: None, model_profile: None, diff --git a/crates/opensymphony-testkit/src/lib.rs b/crates/opensymphony-testkit/src/lib.rs index 0fc2139b3..2cec41801 100644 --- a/crates/opensymphony-testkit/src/lib.rs +++ b/crates/opensymphony-testkit/src/lib.rs @@ -219,7 +219,7 @@ impl FakeSearchScript { #[derive(Debug, Clone)] pub enum FakeSocketAction { - Event(EventEnvelope), + Event(Box), Text(String), Ping(Vec), Close, @@ -236,7 +236,7 @@ impl FakeSocketScript { } pub fn event(mut self, event: EventEnvelope) -> Self { - self.actions.push(FakeSocketAction::Event(event)); + self.actions.push(FakeSocketAction::Event(Box::new(event))); self } diff --git a/crates/opensymphony-workflow/src/acp.rs b/crates/opensymphony-workflow/src/acp.rs new file mode 100644 index 000000000..41103918a --- /dev/null +++ b/crates/opensymphony-workflow/src/acp.rs @@ -0,0 +1,194 @@ +//! Secret-free ACP launch configuration. Values are resolved only at process launch. +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{RoutingConfig, WorkflowConfigError}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AcpConfig { + #[serde(default)] + pub profiles: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AcpProfile { + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default = "stdio")] + pub transport: String, + #[serde(default = "v1")] + pub protocol_versions: Vec, + #[serde(default)] + pub env_refs: BTreeMap, + pub auth: Option, + #[serde(default)] + pub required_capabilities: Vec, + #[serde(default)] + pub extensions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AcpAuth { + pub method_id: String, +} + +fn stdio() -> String { + "stdio".into() +} +fn v1() -> Vec { + vec![1] +} + +pub(crate) fn valid_env_name(value: &str) -> bool { + let mut chars = value.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn valid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) +} + +fn invalid(message: &str) -> WorkflowConfigError { + WorkflowConfigError::InvalidField { + field: "acp", + message: message.into(), + } +} + +impl AcpProfile { + pub fn validate(&self) -> Result<(), WorkflowConfigError> { + if self.command.trim().is_empty() || self.command.contains(['\0', '\n', '\r', '$']) { + return Err(invalid( + "command must be a nonempty executable, without environment interpolation", + )); + } + if self.args.len() > 128 + || self.args.iter().any(|a| { + a.len() > 8192 + || a.contains(['\0', '\n', '\r', '$']) + || [ + "--api-key", + "--access-token", + "--oauth2-bearer", + "--client-secret", + "--password", + "--secret", + "--pat", + "--token", + ] + .iter() + .any(|flag| { + a.split('=') + .next() + .is_some_and(|name| name.eq_ignore_ascii_case(flag)) + }) + }) + { + return Err(invalid( + "args must be bounded literal argv entries; credentials belong in env_refs", + )); + } + if self.transport != "stdio" || self.protocol_versions != [1] { + return Err(invalid( + "only ACP protocol_versions: [1] with transport: stdio is implemented", + )); + } + if self.env_refs.len() > 128 + || self + .env_refs + .iter() + .any(|(k, v)| !valid_env_name(k) || !valid_env_name(v)) + { + return Err(invalid( + "env_refs must map environment variable names to variable names", + )); + } + if self.auth.as_ref().is_some_and(|a| { + a.method_id.is_empty() + || a.method_id.len() > 1024 + || a.method_id.chars().any(char::is_control) + }) { + return Err(invalid( + "auth.method_id must be a nonempty bounded opaque identifier without control characters", + )); + } + if !self.extensions.is_empty() { + return Err(invalid( + "no extension handlers are implemented; extensions must be empty", + )); + } + if self.required_capabilities.iter().any(|c| { + !["prompt.image", "prompt.audio", "prompt.embedded_context"].contains(&c.as_str()) + }) { + return Err(invalid( + "unknown required capability; supported names: prompt.image, prompt.audio, prompt.embedded_context", + )); + } + Ok(()) + } +} + +impl AcpConfig { + pub fn validate_profiles(&self) -> Result<(), WorkflowConfigError> { + for (id, profile) in &self.profiles { + if !valid_id(id) { + return Err(invalid("profile IDs must be nonempty stable identifiers")); + } + profile + .validate() + .map_err(|error| invalid(&format!("profile `{id}`: {error}")))?; + } + Ok(()) + } + + pub fn validate_selection( + &self, + harness: &str, + profile: Option<&str>, + model_override: bool, + ) -> Result<(), WorkflowConfigError> { + self.validate_profiles()?; + match (harness, profile) { + ("acp", Some(id)) if self.profiles.contains_key(id) => {} + ("acp", _) => { + return Err(invalid( + "routing.harness_profile must name a configured ACP profile", + )); + } + (_, Some(_)) => { + return Err(invalid( + "routing.harness_profile requires routing.harness: acp", + )); + } + _ => {} + } + if harness == "acp" && model_override { + return Err(invalid( + "ACP model overrides require session configuration support, which is not implemented", + )); + } + Ok(()) + } +} + +impl AcpConfig { + pub fn validate_route(&self, routing: &RoutingConfig) -> Result<(), WorkflowConfigError> { + self.validate_selection( + &routing.harness, + routing.harness_profile.as_deref(), + routing.model.is_some() || routing.model_profile.is_some(), + ) + } +} diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index 780547d35..077c19548 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -1,3 +1,5 @@ +mod acp; +pub use acp::{AcpAuth, AcpConfig, AcpProfile}; mod error; mod loader; mod model; diff --git a/crates/opensymphony-workflow/src/model.rs b/crates/opensymphony-workflow/src/model.rs index 54ced5831..0da7a7919 100644 --- a/crates/opensymphony-workflow/src/model.rs +++ b/crates/opensymphony-workflow/src/model.rs @@ -61,6 +61,8 @@ pub struct WorkflowFrontMatter { #[serde(default)] pub routing: RoutingFrontMatter, #[serde(default)] + pub acp: super::acp::AcpConfig, + #[serde(default)] pub codex: Option>, #[serde(default)] pub logging: Option>, @@ -120,6 +122,7 @@ pub struct AgentFrontMatter { #[serde(deny_unknown_fields)] pub struct RoutingFrontMatter { pub harness: Option, + pub harness_profile: Option, pub model: Option, pub model_profile: Option, pub harness_env: Option, @@ -271,6 +274,7 @@ pub struct WorkflowConfig { #[derive(Debug, Clone, PartialEq)] pub struct WorkflowExtensions { + pub acp: super::acp::AcpConfig, pub openhands: OpenHandsConfig, } @@ -324,6 +328,7 @@ pub struct AgentConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RoutingConfig { pub harness: String, + pub harness_profile: Option, pub model: Option, pub model_profile: Option, pub harness_env: String, diff --git a/crates/opensymphony-workflow/src/resolve.rs b/crates/opensymphony-workflow/src/resolve.rs index 24bc5658c..966438cdf 100644 --- a/crates/opensymphony-workflow/src/resolve.rs +++ b/crates/opensymphony-workflow/src/resolve.rs @@ -43,6 +43,7 @@ pub(crate) fn resolve_workflow( env: &E, ) -> Result { let routing = resolve_routing(&workflow.front_matter.routing, env)?; + workflow.front_matter.acp.validate_route(&routing)?; let config = WorkflowConfig { tracker: resolve_tracker(&workflow.front_matter.tracker, env)?, polling: resolve_polling(&workflow.front_matter.polling)?, @@ -52,6 +53,7 @@ pub(crate) fn resolve_workflow( routing, }; let mut extensions = WorkflowExtensions { + acp: workflow.front_matter.acp.clone(), openhands: if config.routing.harness == DEFAULT_ROUTING_HARNESS { resolve_openhands(&workflow.front_matter.openhands, base_dir, env)? } else { @@ -342,8 +344,15 @@ fn resolve_routing( let model_profile_from_env = model_profile_override.is_some(); let model_profile = model_profile_override.or(configured_model_profile); + let harness_profile = if harness_from_env && harness != "acp" { + None + } else { + routing.harness_profile.clone() + }; + Ok(RoutingConfig { harness, + harness_profile, model, model_profile, harness_env, @@ -357,13 +366,13 @@ fn resolve_routing( } fn validate_harness_kind(value: &str, field: &'static str) -> Result<(), WorkflowConfigError> { - if HarnessKind::parse(value).is_some() { + if value == "acp" || HarnessKind::parse(value).is_some() { Ok(()) } else { Err(WorkflowConfigError::InvalidField { field, message: format!( - "must be one of `{}`", + "must be one of `{}`, `acp`", HarnessKind::supported_names().join("`, `") ), }) diff --git a/crates/opensymphony-workspace/src/environment.rs b/crates/opensymphony-workspace/src/environment.rs new file mode 100644 index 000000000..168df720b --- /dev/null +++ b/crates/opensymphony-workspace/src/environment.rs @@ -0,0 +1,83 @@ +//! Platform-aware process environment names and explicit value precedence. +use std::collections::{BTreeMap, BTreeSet}; + +pub fn environment_variable_names_equal(left: &str, right: &str) -> bool { + if cfg!(windows) { + left.eq_ignore_ascii_case(right) + } else { + left == right + } +} + +/// Configured targets must be distinct under the host environment's name rules. +pub(crate) fn has_environment_name_collision<'a>(names: impl IntoIterator) -> bool { + let mut seen = BTreeSet::new(); + names.into_iter().any(|name| { + let name = if cfg!(windows) { + name.to_ascii_uppercase() + } else { + name.to_owned() + }; + !seen.insert(name) + }) +} + +/// Replace inherited aliases before a map is applied with `Command::envs`. +/// Its lexical iteration order must not choose which credential wins on Windows. +pub(crate) fn insert_environment_value( + environment: &mut BTreeMap, + name: String, + value: String, +) { + environment.retain(|key, _| !environment_variable_names_equal(key, &name)); + environment.insert(name, value); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configured_environment_targets_reject_platform_equivalent_duplicates() { + assert!(has_environment_name_collision([ + "AGENT_TOKEN", + "AGENT_TOKEN" + ])); + assert_eq!( + has_environment_name_collision(["AGENT_TOKEN", "agent_token"]), + cfg!(windows) + ); + assert!(!has_environment_name_collision([ + "AGENT_TOKEN", + "OTHER_TOKEN" + ])); + } + + #[test] + fn resolved_alias_wins_in_the_child_environment() { + for inherited_name in ["ACP_TEST_ALIAS", "acp_test_alias"] { + let mut environment = BTreeMap::from([ + (inherited_name.into(), "stale-value".into()), + ("ACP_OTHER_VALUE".into(), "retained-value".into()), + ]); + insert_environment_value( + &mut environment, + "ACP_TEST_ALIAS".into(), + "resolved-value".into(), + ); + let output = std::process::Command::new(if cfg!(windows) { "python" } else { "python3" }) + .arg("-c") + .arg("import os; assert os.environ['ACP_TEST_ALIAS'] == 'resolved-value'; assert os.environ['ACP_OTHER_VALUE'] == 'retained-value'; assert os.name == 'nt' or os.environ['acp_test_alias'] == 'stale-value'") + // Keep a distinct lowercase variable on case-sensitive hosts. + .env("acp_test_alias", "stale-value") + .envs(environment) + .output() + .expect("child environment probe"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + } +} diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index c7334ee53..6eb300ccd 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -1,8 +1,14 @@ +mod environment; mod error; mod manager; mod models; mod paths; +mod sensitive_fields; +pub(crate) use sensitive_fields::{normalize_secret_field_name, runtime_field_is_sensitive}; + +pub use environment::environment_variable_names_equal; +pub(crate) use environment::{has_environment_name_collision, insert_environment_value}; pub use error::{WorkspaceError, WorkspaceOwnershipConflictDetails}; pub use manager::{ WorkspaceManager, compose_parent_continuation_prompt, compose_parent_prompt, @@ -18,10 +24,13 @@ pub use models::{ ParentRetainedCheckout, ParentRuntimeCheckout, ParentRuntimeDescriptor, ParentRuntimeEnvelope, PromptCaptureDescriptor, PromptCaptureManifest, PromptKind, RunDescriptor, RunManifest, RunStatus, SSH_AUTH_SOCK_ENV, SessionContextArtifact, TerminalRuntimeEnvelope, WorkspaceHandle, - WorkspaceManagerConfig, checkout_credential_environment_variables, - environment_variable_names_equal, redact_runtime_diagnostic, + WorkspaceManagerConfig, checkout_credential_environment_variables, redact_runtime_diagnostic, }; pub use paths::{ checkout_workspace_key, parent_workspace_key, resolve_path_within_root, sanitize_workspace_key, workspace_path_for_root, }; + +#[cfg(unix)] +pub(crate) use manager::ProcessGroupGuard; +pub(crate) use manager::{configure_process_group, terminate_process_tree}; diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index dde850b41..043a978a6 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -7698,12 +7698,12 @@ async fn join_child_pipe( } #[cfg(unix)] -fn configure_process_group(command: &mut Command) { +pub(crate) fn configure_process_group(command: &mut Command) { command.process_group(0); } #[cfg(not(unix))] -fn configure_process_group(_command: &mut Command) {} +pub(crate) fn configure_process_group(_command: &mut Command) {} fn sanitize_git_environment(command: &mut Command) { for variable in [ @@ -7717,7 +7717,7 @@ fn sanitize_git_environment(command: &mut Command) { } #[cfg(unix)] -async fn terminate_process_tree( +pub(crate) async fn terminate_process_tree( _child: &mut tokio::process::Child, process_id: Option, ) -> io::Result<()> { @@ -7745,7 +7745,7 @@ async fn terminate_process_tree( } #[cfg(windows)] -async fn terminate_process_tree( +pub(crate) async fn terminate_process_tree( child: &mut tokio::process::Child, process_id: Option, ) -> io::Result<()> { @@ -7771,7 +7771,7 @@ async fn terminate_process_tree( } #[cfg(not(any(unix, windows)))] -async fn terminate_process_tree( +pub(crate) async fn terminate_process_tree( child: &mut tokio::process::Child, _process_id: Option, ) -> io::Result<()> { @@ -7779,18 +7779,18 @@ async fn terminate_process_tree( } #[cfg(unix)] -struct ProcessGroupGuard(Option); +pub(crate) struct ProcessGroupGuard(Option); #[cfg(unix)] impl ProcessGroupGuard { - fn new(process_id: Option) -> Self { + pub(crate) fn new(process_id: Option) -> Self { let process_group = process_id .and_then(|process_id| i32::try_from(process_id).ok()) .and_then(Pid::from_raw); Self(process_group) } - fn disarm(&mut self) { + pub(crate) fn disarm(&mut self) { self.0 = None; } } diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index 449d05602..701076ec6 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -43,14 +43,6 @@ pub struct CheckoutRepository { pub const SSH_AUTH_SOCK_ENV: &str = "SSH_AUTH_SOCK"; -pub fn environment_variable_names_equal(left: &str, right: &str) -> bool { - if cfg!(windows) { - left.eq_ignore_ascii_case(right) - } else { - left == right - } -} - pub fn checkout_credential_environment_variables( repositories: &BTreeMap, ) -> BTreeSet { diff --git a/crates/opensymphony-workspace/src/sensitive_fields.rs b/crates/opensymphony-workspace/src/sensitive_fields.rs new file mode 100644 index 000000000..f3a3d4e2e --- /dev/null +++ b/crates/opensymphony-workspace/src/sensitive_fields.rs @@ -0,0 +1,59 @@ +//! Shared sensitive field classification for configuration and protocol evidence. + +pub(crate) fn runtime_field_is_sensitive(name: &str) -> bool { + // Protocol/config fields use hyphens, underscores, camelCase and acronyms. + let name = normalize_secret_field_name(name); + [ + "access_token", + "api_key", + "apikey", + "authorization", + "access_key", + "accesskey", + "account_id", + "accountid", + "account_identifier", + "account_identity", + "chatgpt_account_id", + "credential", + "password", + "pat", + "secret", + "token", + ] + .iter() + .any(|part| name == *part || name.ends_with(&format!("_{part}"))) +} + +pub(crate) fn normalize_secret_field_name(name: &str) -> String { + let characters = name.chars().collect::>(); + let mut normalized = String::with_capacity(name.len()); + for (index, character) in characters.iter().copied().enumerate() { + if character == '-' { + if !normalized.ends_with('_') { + normalized.push('_'); + } + continue; + } + if character.is_ascii_uppercase() { + let previous_is_lower_or_digit = characters + .get(index.wrapping_sub(1)) + .is_some_and(|previous| previous.is_ascii_lowercase() || previous.is_ascii_digit()); + let previous_is_acronym_boundary = characters + .get(index.wrapping_sub(1)) + .is_some_and(|previous| previous.is_ascii_uppercase()) + && characters + .get(index + 1) + .is_some_and(|next| next.is_ascii_lowercase()); + if (previous_is_lower_or_digit || previous_is_acronym_boundary) + && !normalized.ends_with('_') + { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push(character.to_ascii_lowercase()); + } + } + normalized +} diff --git a/docs/architecture.md b/docs/architecture.md index f55a96dde..b2ebee180 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -477,6 +477,22 @@ Notable removals: - the old bridge CLI command - provider-specific AI review secret naming +## ACP executable protocol client + +The `opensymphony_acp` internal module uses the official Rust ACP SDK for typed +requests, JSON-RPC correlation and ordered application dispatch. OpenSymphony owns +the child process and supplies bounded LF framing to the SDK line transport. +The initial `run_turn` API creates one session, runs one prompt, and tears down +its child. It has no scheduler mutation authority. Host-owned retained sessions, +recovery and production worker routing are defined by the +[ACP runtime task package](tasks/acp-runtime-ide-task-package.yaml). + +Handlers are installed before initialization. Permission callbacks receive the +protocol cancellation outcome; unknown requests receive method-not-found and +unknown notifications receive no response. Updates are processed before the +prompt response is returned. The client advertises no filesystem or terminal +capabilities. No OpenHands server or client participates in this launch path. + ## Current model diff --git a/docs/configuration.md b/docs/configuration.md index 4c71c8c36..b09d8ff73 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -928,6 +928,69 @@ lives in the OpenSymphony docs at [ai-pr-review-human-setup.md](ai-pr-review-human-setup.md); `init` does not copy that guide into the target repository. +## ACP stdio profiles + +Workflow front matter and central configuration accept the same typed `acp.profiles` +map and `routing.harness_profile` selector: + +```yaml +routing: + harness: acp + harness_profile: local +acp: + profiles: + local: + command: /absolute/path/to/agent + args: [acp] + transport: stdio + protocol_versions: [1] + env_refs: + AGENT_API_KEY: OPERATOR_AGENT_API_KEY + auth: + method_id: agent_login +``` + +Central configuration migration transfers `routing.harness_profile` and the full +`acp.profiles` map from the legacy workflow before rewriting its prompt body. +Environment references remain names throughout migration. At launch, each source +variable is removed from the child environment unless that name is also an +explicit target in `env_refs`; only the intended credential aliases are passed. + +The executable ACP client is available through `opensymphony_acp::run_turn`. +Production `opensymphony run` routing is a separate implementation slice; an ACP +route currently fails scheduler capability selection before worker dispatch. +Central profiles remain authoritative over repository-local workflow files. +Profile shape is validated at central load with the profile ID and specific +validation cause. Harness/profile selection and model restrictions are validated +after workflow environment overrides resolve. An explicit harness environment +override to another harness clears the ACP selector; an override to ACP retains +it. Windows environment references +use case-insensitive variable names; a resolved alias replaces inherited target +values regardless of their casing. Configured target names must themselves be +distinct under host name rules; Windows rejects case-equivalent `env_refs` targets +before launch, while POSIX preserves case-distinct variables. +Profile arguments are literal argv entries, never shell templates. `env_refs` +contains variable names; resolved values stay in the host-owned launch context. +The selected authentication method must be an advertised agent-handled method; +terminal/browser authentication is not advertised. Omit `auth` for agents with +existing login state that need no `authenticate` call. + +Profiles reject cwd overrides, unsupported wire versions/transports, extension +handlers, credential arguments regardless of flag casing, and invalid environment +references. Optional +`required_capabilities` supports `prompt.image`, `prompt.audio`, and +`prompt.embedded_context`; each requirement is checked before session creation, +and a failed check names the missing capability. +The current prompt API sends text. ACP model overrides are rejected until the +session configuration implementation is available. Filesystem, terminal, MCP, +extension, and operator permission policies are follow-on slices. + +ACP credential arguments such as `--access-token`, `--oauth2-bearer`, +`--client-secret`, and `--pat` are rejected in separate-value and equals forms, +including mixed case; credentials belong in `env_refs`. A top-level `acp` section +selects the central configuration parser, so incomplete central files fail +validation before legacy defaults can be applied. + ## Current model diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 5a68d6c52..df5194e34 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -113,3 +113,12 @@ Known gaps: - Concrete SDK/runtime selection is not implemented yet. - Hosted execution would need an isolation model before remote support is advertised. + +## ACP stdio client foundation + +`opensymphony_acp::run_turn` implements an SDK-backed ACP v1 stdio session through +setup, one text prompt, ordered updates, cancellation, and supervised teardown. +Only the executable client and typed launch profiles are available in this slice. +ACP is not yet advertised as a runnable scheduler harness; production routing, +retained sessions, negotiated public capabilities, complete callbacks and operator +responses follow the [ACP runtime task package](tasks/acp-runtime-ide-task-package.yaml). diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 2ee442bff..a6e03c613 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -196,8 +196,7 @@ of broadening the existing hardening milestone: | M10.6 Desktop Run Detail Operations And Interrupts | after M10.5 OKF memory, before M14 hosted alpha | harness interrupt contract, OpenHands/Codex cancel wiring, Human Review to Merging supersede, Run Detail action cleanup, TUI parity, lazy desktop launcher | | M10.7 Project Grouping And Dependency Signals | after M10.6 desktop operations, before M14 hosted alpha | control-plane project/dependency metadata, TUI project headers and dependency gutter, desktop grouping and collapse | | M11.5 LLM Wiki Graph View | after M14 hosted identity and visibility foundations, before provider/harness readiness | memory graph DTOs, graph extraction, shared frontend package, Three.js renderer, inspector/accessibility, live privacy gates | -| M13 ACP Debugging And IDE Attach | after M12 harness/model seams, before M15 release hardening | debug attachment refactor, ACP stdio server, Zed setup, Tauri Debug in Zed action, default debug UX transition, integration tests | -| M12.6 Tree-sitter Code Intelligence | after M13 ACP debugging, before M15 release hardening | trusted Tree-sitter provider, memory-context AST integration, query packs, persistence, read-only MCP/CLI AST tools, performance and docs | +| M12.6 Tree-sitter Code Intelligence | after memory foundations, before M12.9 code graph and M15 release hardening | trusted Tree-sitter provider, memory-context AST integration, query packs, persistence, read-only MCP/CLI AST tools, performance and docs | | M12.7 Workflow Target Branch Configuration | after M12.6 code intelligence, before M15 release hardening | configurable target branch markers, init prompt and flag, marker-only update settings mode, template/docs hardening | | M12.8 Desktop App Installer And Auto-Update | after M12.7 workflow target branch configuration, before M15 release hardening | lazy desktop installer/launcher, verified release bundles, source-build fallback, auto-update prompts, and installer docs | | M12.85 Codex Thread Lifecycle | after M10.3 Codex production harness, before M15 release hardening | manifest-backed per-issue thread reuse, retained terminal workspaces, durable archive transitions, and debug/reopen recovery | @@ -205,6 +204,8 @@ of broadening the existing hardening milestone: | M12.95 Multi-Repository Foundations | after M12.9 target snapshots/overlays and completed COE-448 memory-server foundations | central instance config, canonical task binding, verified per-repository checkouts/instructions, per-instance memory catalog, and scoped leaf memory | | M12.96 Parent Integration Lifecycle | after M12.95 foundations, before multi-repository operator rollout | hierarchy generations, durable leases, retained-child worktree reuse, restart-safe parent integration, repair PRs, and bottom-up cleanup | | M12.97 Multi-Repository Operations And Rollout | after M12.96 parent lifecycle, before M15 release hardening | cross-client operator truth, hermetic lifecycle/fault validation, migration rollback, and isolated non-production activation | +| M12.99 ACP Harness Runtime Adapter | on current native-harness and multi-repository foundations, before M13 qualification | executable ACP v1 stdio client, profiles, host-owned sessions, concrete worker routing, callbacks, operator response delivery, extensions and two-harness live qualification | +| M13 ACP Debugging And IDE Attach | builds on M12.99 routing and session ownership, before M15 release hardening | multi-harness attachment, scheduler/IDE control handoff, ACP bridge fidelity, static editor setup, desktop launch and qualification before default UX transition | M10.3 pulls the shared harness adapter, model/credential settings, OpenHands subscription credential adapter, model configuration UI metadata, Codex @@ -258,10 +259,27 @@ finally they expose truthful operator state and gate activation on deterministic fault-injected validation. The wave reuses completed COE-448 memory-server work and M12.9 code snapshots/overlays instead of creating parallel implementations. -The current default planning source is `docs/tasks/task-package.yaml` for the -`multi-repository-orchestration` wave. The ACP debugging wave remains in -`docs/tasks/advanced-knowledge-debug-task-package.yaml`, and -`docs/tasks/linear-publish.yaml` records the latest published planning wave. +M12.99 follows `docs/specs/acp-harness-adapter.md`. It delivers a working generic +ACP runtime through the real `opensymphony run` path, including configured +profile selection, retained host-owned sessions, filesystem/terminal callbacks, +scoped MCP setup, operator replies and registered extension operations. Adding +capability metadata without executable routing and response delivery does not +satisfy this milestone. ACP v2 and network transports are follow-on work. + +M13 follows `docs/specs/opensymphony-acp-debugging-spec.md`. All supported ACP +profiles are core IDE attachment targets. A separate editor bridge attaches to +the same host-owned session; the orchestrator holds scheduling while transferring +exclusive control. The bridge preserves ACP source events and supported extension +semantics. OpenHands and native Codex debug compatibility remain explicit. Two +live harnesses and actual editor attachment must pass qualification before the +default debug UX changes. + +The default `docs/tasks/task-package.yaml` and `docs/tasks/linear-publish.yaml` +track `multi-repository-orchestration`. The ACP runtime and IDE wave uses +`docs/tasks/acp-runtime-ide-task-package.yaml` and its matching +`acp-runtime-ide-linear-publish.yaml`, retaining the existing M13 issue identities. +`docs/tasks/advanced-knowledge-debug-task-package.yaml` contains the OKF and wiki +graph work; its published mapping remains available for those original issues. diff --git a/docs/operations.md b/docs/operations.md index 82e522080..4a4e28a7d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -885,6 +885,61 @@ If an older target repo still contains `openhands.mcp`, remove that block. OpenSymphony 1.0.0 expects Linear access through `LINEAR_API_KEY` and the repo-local GraphQL helper assets copied by `opensymphony init`. +## ACP client validation and limits + +`cargo test-system-duckdb --test acp` launches the reusable Python fake peer through +`opensymphony_acp::run_turn`, the same executable client API intended for host +integration. It requires Python 3, no provider credential, and creates a distinct +issue checkout inside a temporary workspace root. These subprocess tests establish +the client contract; real vendor qualification remains OSYM-906. + +The default limits are 1 MiB per frame, 128 queued incoming frames with a +cumulative 4 MiB wire-byte budget, 128 outstanding callback responses with an +independent 4 MiB encoded-byte budget, 256 retained source frames with a cumulative +1 MiB serialized evidence budget, 16 KiB stderr, 30 seconds for setup, 300 seconds +for a prompt, 10 seconds for cancellation acknowledgement, and one 5-second +deadline for process termination and reaping. Windows launches enter a kill-on-close Job Object before +the child resumes, so dropping the turn future also terminates descendants. Unix +launches retain process-group ownership for the same drop path. Callers +may pass validated `ClientLimits`. A supplied update channel must be drained +concurrently; saturation or receiver loss fails the run visibly. Incoming queue +charges are released when each frame reaches SDK dispatch. Its byte budget is +independent of frame count, ranges from 256 bytes to 64 MiB, and rejects a single +frame that exceeds it even when the per-frame limit is larger. Callback output +reserves count and encoded bytes (including LF) before SDK enqueue and releases +these only after stdin writes flush. Its count ranges from 1 to 4,096 and its byte +budget from 256 bytes to 64 MiB. Saturation fails promptly with ResourceLimit and +reaps the child, including when the peer stops reading stdin. Session creation +binds the new session ID in an ordered SDK response callback before subsequent +updates or permission requests are dispatched. Evidence capture +marks truncation when either the frame count or byte budget is exhausted. The byte +budget includes redacted payloads and source metadata, can be configured up to +16 MiB, and can be zero to disable retention. Frames that exceed the remaining +budget are omitted while protocol processing continues. Capture redacts secrets +and sensitive fields, including normalized account-identity keys; diagnostic +string previews are limited to 512 characters. Live update content preserves +whitespace and complete strings while removing known secrets and sensitive +fields; it does not use diagnostic preview normalization. Stderr overflow is replaced with a +limit marker while the pipe continues draining. SDK wire tracing is disabled for +this connection to prevent bypassing the redacted evidence surface. Known secrets +use one multi-pattern scan per string; matcher inputs are limited to 1,024 distinct +values and 1 MiB combined. Generic diagnostic normalization examines at most 2,048 +characters after full known-secret redaction and emits a 512-character preview. +Non-authentication RPC errors retain the request method, numeric code, submission +state and bounded redacted message even when source-frame retention is disabled. + +Cancellation before prompt submission interrupts setup and tears down the child; +an already-cancelled token prevents launch. The complete serialized prompt frame +must pass its size bound before submission becomes uncertain. Authentication +errors retain that submission state, so a mid-turn token failure cannot be +treated as a safe pre-submission login failure. Opaque session IDs remain +available for correlation and are omitted from diagnostic `Debug` output. + +Only an original prompt response with `stopReason: cancelled` acknowledges a +requested cancellation. Sending `session/cancel`, killing a process, or receiving +an unrelated stop reason does not establish that acknowledgement. Prompt failures +after possible submission are uncertain and are never retried by this client. + ## Current model diff --git a/docs/sources.md b/docs/sources.md index 335509697..89d1a13e7 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -229,6 +229,26 @@ Key takeaways carried forward: - Session-oriented harness integration is the right abstraction. - Real-time runtime streaming is important enough to build in early rather than retrofit later. +## ACP executable client baseline + +The client pins [`agent-client-protocol` 2.2.0](https://crates.io/crates/agent-client-protocol/2.2.0) +and [`agent-client-protocol-schema` 1.9.1](https://crates.io/crates/agent-client-protocol-schema/1.9.1). +The released SDK records source commit +[`2a78849d3eb3dcb140dade3b8fc938cf1e2b9ce5`](https://github.com/agentclientprotocol/rust-sdk/tree/2a78849d3eb3dcb140dade3b8fc938cf1e2b9ce5). +These package versions are distinct from negotiated ACP wire version 1. +The implementation uses the SDK's `Lines` transport, `Client` builder, typed v1 +setup requests, raw SDK prompt response decoding for future stop reasons, and +ordered `Dispatch` handler. ACP v2 features are disabled. +Windows process ownership uses the safe Job Object API from +[`process-wrap` 10.0.0](https://docs.rs/process-wrap/10.0.0/process_wrap/): +`JobObject` assigns the suspended child before resuming it, and `KillOnDrop` +terminates descendants when ownership ends. + +Wire authority: [v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization), +[v1 prompt/cancel lifecycle](https://agentclientprotocol.com/protocol/v1/prompt-turn), +and [v1 transports](https://agentclientprotocol.com/protocol/v1/transports). +Implementation and tests are in `crates/opensymphony-acp/src/lib.rs` and `tests/acp.rs`. + ## Current model diff --git a/docs/specs/acp-harness-adapter.md b/docs/specs/acp-harness-adapter.md new file mode 100644 index 000000000..da19c16a2 --- /dev/null +++ b/docs/specs/acp-harness-adapter.md @@ -0,0 +1,485 @@ +# ACP harness adapter design + +Status: proposed implementation design. Research date: 2026-09-22. +Repository baseline: `50d8bb777d828515ffacdcd2c2ce9bbc9d3dba52`. +This document specifies behavior to implement; it does not claim an operational ACP adapter. + +## Decision + +Add one generic **ACP client adapter**, exposed as harness kind `acp`, with named +agent profiles. Keep OpenHands agent-server and Codex app-server as native +adapters. ACP is an additional execution boundary; OpenSymphony retains its own +orchestration, workspace, policy, and event models. + +A new agent that implements the supported ACP version and transport should need +only a launch profile. A custom extension requires either a declarative operation +description or a small Rust handler when its behavior must be interpreted. Adding +an agent must not require a new scheduler harness enum variant. + +M12.99 ships stable ACP v1 over local stdio. ACP v2 and network transports are +follow-on work requiring a concrete endpoint and verified lifecycle contract. The interoperability promise is **any conforming +agent within the supported version, transport, authentication, and capability +matrix**. ACP compliance alone does not establish restart recovery, unattended +operation, remote workspace access, or support for arbitrary vendor features. + +```mermaid +flowchart TB + S[OpenSymphony scheduler] --> W[WorkerBackend and worker commands] + W --> N[Native adapters: OpenHands and Codex] + W --> A[Host-owned issue session] + D[IDE debug bridge via control plane] <--> A + A --> C[ACP client: lifecycle and ordered events] + C <--> P[Configured agent process] + C <--> H[Client callbacks and extension handlers] + H --> O[Operator requests and policy decisions] + O --> W + C --> E[WorkerUpdate and private source evidence] + E --> S +``` + +## Repository fit + +The current [HarnessAdapter trait](https://github.com/kumanday/OpenSymphony/blob/50d8bb777d828515ffacdcd2c2ce9bbc9d3dba52/crates/opensymphony-domain/src/harness.rs) +provides capability discovery. Execution runs through +[WorkerBackend, HarnessRouteDecision, and WorkerUpdate](https://github.com/kumanday/OpenSymphony/blob/50d8bb777d828515ffacdcd2c2ce9bbc9d3dba52/crates/opensymphony-orchestrator/src/scheduler.rs) +and the [CLI worker backend](https://github.com/kumanday/OpenSymphony/blob/50d8bb777d828515ffacdcd2c2ce9bbc9d3dba52/crates/opensymphony-cli/src/orchestrator_run/backends.rs). +Implement both the discovery and execution paths. + +The backend contains two-harness assumptions: Codex-specific dispatch followed +by the OpenHands path, a Boolean Codex/non-Codex session-switch comparison, +recovery eligibility, and separate interrupt handling. Replace these with +explicit matching on harness identity at those boundaries. Compare ACP profile +identity as well as harness kind before reusing a session. Merely adding `acp` +to discovery would leave lifecycle and recovery behavior incorrect. + +Add `crates/opensymphony-acp/src/` as an internal source module included from +`src/lib.rs`, consistent with the single published crate. Keep protocol types, +session lifecycle, callback implementation, and vendor handlers there. Put only +execution wiring in the CLI backend. Reuse workspace preparation, hook execution, +instruction provenance, runtime envelopes, memory grants, and worker reporting. +The ACP path must not start a managed OpenHands server or instantiate an +OpenHands execution client as a prerequisite. + +Use the official Rust `agent-client-protocol` SDK for protocol types, RPC +correlation, and dispatch. The inspected SDK source reports version `2.2.0`; +its package version is separate from the negotiated ACP wire version. Pin the +validated dependency and schema versions during implementation. Use OpenSymphony +process ownership with an SDK stream transport if the SDK launcher cannot meet +cwd, environment scrubbing, shutdown, or capture requirements. Verify Tokio +integration and frame limits in the first implementation slice. Do not copy the +Codex JSON-RPC client or add a TypeScript bridge. +Sources: [SDK source at inspected commit](https://github.com/agentclientprotocol/rust-sdk/blob/28688b2d97a81975ff875180c3a3e46e6cad0161/src/agent-client-protocol/Cargo.toml), +[SDK design](https://agentclientprotocol.github.io/rust-sdk/design.html). + +The [M13 IDE attachment specification](opensymphony-acp-debugging-spec.md) defines +OpenSymphony as an ACP server for an editor. Both protocol roles share the same +host-owned runtime session through control-plane commands and events. Their RPC +identities and negotiation are independent; their execution ownership is shared. + +## Configuration and identity + +Proposed workflow syntax, not accepted by the current parser: + +```yaml +routing: + harness: acp + harness_profile: cursor-local + +acp: + profiles: + cursor-local: + command: agent + args: [acp] + transport: stdio + protocol_versions: [1] + env_refs: + CURSOR_API_KEY: CURSOR_API_KEY + auth: + method_id: cursor_login + permissions: + mode: operator + extensions: [cursor] +``` + +`command` plus `args` is an argv vector, never a shell template. Both process cwd +and session cwd come from the scheduler-bound, verified issue workspace. Profiles +cannot override that directory. Environment entries reference operator-owned +variables; secrets never become literal workflow values, command-line arguments, +manifests, or diagnostic output. Apply the existing checkout-credential exclusions +and scoped memory environment before launch. + +Add a typed ACP configuration section and `routing.harness_profile` through both +workflow and central configuration resolution. Validate profile references, +supported transports and versions, and handler names before dispatch. Optional +capabilities may degrade; explicitly required capabilities fail before prompting. +For ACP, resolve `routing.model` through the agent's advertised model/config +options. Reject unsupported explicit overrides instead of silently using the +default. Existing model profiles need an explicit ACP mapping; OpenHands model +and credential semantics must not be forwarded blindly. + +Persist this identity with the existing run and conversation envelope: + +- Harness kind `acp`, profile ID, and non-secret profile fingerprint. +- Actual agent name/version when reported, executable/package identity when known, + ACP version, and SDK/schema versions. +- Opaque session ID, issue/repository/workspace binding, run/attempt identity, and + connection generation. +- Effective capability snapshot, enabled extension contract versions, and actual + configured model/options. +- Prompt submission state, completion/cancellation evidence, and recovery status. + +Profile IDs are stable identifiers; display names are labels. An agent name is +self-reported metadata, not authentication. Session IDs remain opaque and must +not be interpolated directly into filenames. A profile edit, route change, +workspace change, or credential-scope change must pass compatibility validation +before reuse. Recovery uses persisted identity instead of silently picking the +current default profile. + +## Protocol and session lifecycle + +The first supported stack is ACP v1 semantics, JSON-RPC 2.0 envelopes, UTF-8 JSON +with LF framing, over stdio. The ACP transport page still describes Streamable +HTTP as a draft; an SDK's HTTP/WebSocket implementation does not itself establish +universal network interoperability. +[ACP transports](https://agentclientprotocol.com/protocol/v1/transports). + +Use one host-owned supervised process and connection per issue session, with +one outstanding prompt. Workers borrow the session across turns and attempts; +the end of a worker attempt does not itself end the session. Keep eligible idle +sessions under explicit bounded retention policy so an IDE can attach to the +same live context even when the harness lacks persistence. Active work and +attachment/control leases pin the session; limits refuse new launches or +visibly retire eligible idle sessions instead of evicting active ones. + +Expose the owner through existing control-plane commands and ordered events for +separate debug processes. Enforce a single owner and generation fencing; never +launch a second agent to attach to a live session. M12.99 implements the owner, +identity, retention and command/event seam. M13 implements scheduler holds and +exclusive IDE writer transfer. Cleanup closes/reaps the process only after +owned work is stopped and all relevant leases are released. Host/process loss +requires capability-gated restoration; transcript inspection is separate from +restoring agent context. A cross-issue process pool is outside this scope. + +1. Prepare the workspace and run `before_run` through existing host logic. +2. Launch the configured process with bounded stdout frames, bounded stderr + capture, and supervised child-process cleanup. +3. Register incoming request, notification, and closure handlers before sending + `initialize`. Negotiate only implemented versions; missing optional + capabilities mean unsupported. Complete the configured authentication method + if needed. Advertise terminal/browser authentication only when implemented; + unattended runs report an actionable authentication failure otherwise. +4. Create a session, or restore a compatible persisted one. Wait for setup and + configuration to finish before sending a prompt. For a fresh session, send the + full workflow prompt; for a restored one, send continuation guidance. +5. Drive prompt, callbacks, events, interruption, and outcome reporting concurrently + through the adapter actor. The orchestrator alone decides scheduling changes. + +Initialization exchanges versions and capabilities; it is not the OpenHands +WebSocket readiness barrier. +[ACP initialization](https://agentclientprotocol.com/protocol/v1/initialization). + +### Completion and cancellation + +For v1, the response to `session/prompt` carries the turn's stop reason. +`session/cancel` is a notification. Confirm cancellation from the original prompt +response with `cancelled`, after processing preceding updates. Answer pending +permission requests with their cancellation outcome and keep reading until the +terminal response or deadline. Sending the notification is not acknowledgement. +[ACP prompt and cancellation contract](https://agentclientprotocol.com/protocol/v1/prompt-turn). + +OpenSymphony outcome policy: + +| Observation | Adapter result | +| --- | --- | +| `end_turn` | Successful turn; scheduler/tracker policy determines issue completion or continuation. | +| `max_tokens` or `max_turn_requests` | Resource-limited turn; retain reason and use bounded continuation policy. | +| `refusal` | Explicit unsuccessful outcome; no silent retry loop. | +| `cancelled` | Cancellation acknowledged; preserve the requesting interrupt reason. | +| RPC error, EOF, crash, or deadline | Distinct failure/uncertain state, including whether submission may have occurred. | +| Unknown stop reason | Preserve it and surface an unsupported outcome; never infer success. | + +On an interrupt deadline, record timeout before escalating to owned process-tree +termination. Reaping a local process is separate evidence from a protocol-level +cancel acknowledgement. A process that delegates remote work may leave that work +alive; a profile needs a tested stop/reconciliation contract before OpenSymphony +can treat its local exit as proof of remote quiescence. Preserve the workspace +and identity when stop cannot be established. + +Keep optional `$/cancel_request` handling separate from stopping an agent turn. +Use it for supported individual RPC operations; a request cancellation error +does not prove all session execution stopped. +[ACP request cancellation](https://agentclientprotocol.com/protocol/v1/cancellation). + +### Persistence and recovery + +ACP v1 `session/load` is optional and replays conversation history. The currently +documented `session/resume` is separately capability-gated and restores without +history replay; list and close are also optional capabilities. Use advertised +support rather than assuming every method exists because it appears in the docs. +[ACP session setup](https://agentclientprotocol.com/protocol/v1/session-setup). + +Track restoration events as a separate replay segment. Do not count their tools, +tokens, or messages as fresh work. Local sequence numbers provide arrival order; +they are not an agent cursor. History reconstruction does not prove the outcome +of an interrupted prompt or provide exactly-once prompt execution. + +Before sending a prompt, durably record an attempt/submission marker. If a crash +occurs after possible delivery and before a terminal outcome, do not resend it +automatically. Mark the outcome uncertain, stop/reconcile where supported, and +retain the existing run's cleanup fence. Permit a new attempt only after the +scheduler has resolved the prior execution risk. Standard ACP alone supplies no +universal idempotency key or remote-running-state query. + +When a known-finished session cannot be restored, start a fresh session with the +full workflow context and record the reset reason. An explicit profile requirement +for persistence instead produces a clear incompatibility. Never call unsupported +resume/load methods or represent a reset as continuity. + +### ACP v2 + +ACP v2 implementation is outside M12.99. A future implementation must remain +behind explicit configuration and a feature flag while the protocol is draft. Its prompt response acknowledges acceptance; session state updates carry +foreground progress and completion information. Its resume and update semantics +also differ, and its client filesystem/terminal execution APIs are removed. +When adding v2, use separate lifecycle handling selected once per connection; +do not scatter version checks through scheduler code. +[ACP v2 migration](https://agentclientprotocol.com/protocol/v2/migration). + +Do not interpret the next v2 `idle` as completion of a particular prompt without +a supporting guarantee. A v2 profile must establish exclusive session control +and a tested foreground-state boundary before supporting unattended issue runs. +Keep acceptance, requires-action, foreground completion, and background activity +distinct. Ordered delivery alone does not establish prompt attribution. +[SDK application ordering](https://agentclientprotocol.github.io/rust-sdk/ordered-application-dispatch.html). + +## Client callbacks and operator responses + +Implement the standard v1 client surface as part of broad interoperability: + +| Surface | OpenSymphony responsibility | +| --- | --- | +| `session/request_permission` | Preserve all offered option IDs/kinds; apply configured policy or route an operator decision. | +| `fs/read_text_file`, `fs/write_text_file` | Session-bound, contained file access with protocol-correct text/line behavior and resource limits. | +| `terminal/create`, `output`, `wait_for_exit`, `kill`, `release` | Own terminal processes, output limits, exit status, cancellation, and teardown for that session. | +| Session configuration and legacy modes | Validate advertised IDs/values, apply before prompting, consume subsequent config updates. | +| Elicitation, when advertised | Represent structured information requests separately from approval decisions and validate responses. | +| MCP attachments | Supply existing scoped memory/tool servers using transports the agent supports. | + +Filesystem and terminal capabilities are optional on the wire. Advertise them +only when enabled and fully implemented. A narrow first smoke test may disable +them; broad agent qualification must exercise them. +[ACP filesystem](https://agentclientprotocol.com/protocol/v1/file-system), +[ACP terminals](https://agentclientprotocol.com/protocol/v1/terminals), +[ACP configuration](https://agentclientprotocol.com/protocol/v1/session-config-options). + +Use the verified workspace root for filesystem containment, including symlink +and nonexistent write-target handling. Terminal cwd defaults to that workspace; +reject unauthorized alternate roots. Bind terminal IDs and file requests to the +owning connection/session. Process supervision and callback containment are not +a sandbox for a trusted local agent that also has direct host access. + +The current [gateway approvals handler](https://github.com/kumanday/OpenSymphony/blob/50d8bb777d828515ffacdcd2c2ce9bbc9d3dba52/crates/opensymphony-gateway/src/lib.rs#L5202) +returns an empty list, and `ApprovalDecision` has no worker response path in the +inspected CLI/orchestrator code. Therefore operator support requires real wiring: +worker request event → orchestrator-owned pending request → snapshot/gateway → +validated operator command → worker response channel. Reuse `ApprovalRequest` +and `ActionReceipt` for decisions; add a separate input-request payload for +questions. Never fake successful permission forwarding because a DTO exists. + +Define explicit profile policies: `operator`, `deny`, and `allow_once` for an +operator-authorized unattended environment. Default to `operator`; headless +operation without a responder fails clearly or uses an explicitly chosen policy. +Select an option actually offered by the agent, by its semantic kind, and return +its opaque ID. Do not fabricate `allow-once` IDs or silently grant lasting access. +[ACP permission options](https://agentclientprotocol.com/protocol/v1/tool-calls). + +Every pending interaction needs a deadline and cancellation path. Bind decisions +to run, session, connection generation, and request ID; reject stale or duplicate +decisions. Expire them on disconnect and never replay an old approval after +restart. Waiting for input is a visible worker state with a response deadline, +so stall detection and concurrency accounting remain deliberate. Other issues +continue to schedule normally. + +Advertise form elicitation only with reviewable inputs and decline/cancel controls; +forms cannot collect credentials. Advertise URL elicitation only with an +out-of-band flow that shows the destination and obtains user consent before +opening it. Opening a URL does not establish completion of the external action. +[ACP elicitation](https://agentclientprotocol.com/protocol/v1/elicitation). + +## Custom ACP extensions + +Support both directions: OpenSymphony invoking an agent operation, and an agent +requesting a client operation. Use three mechanisms with distinct obligations: + +| Mechanism | Behavior | +| --- | --- | +| `_meta` on supported objects | Preserve namespaced data; forward only configured fields at their documented locations. | +| Custom notification | Retain a bounded private payload; optionally project known content into existing UI/domain events. | +| Custom request | Validate, dispatch to an explicit handler, and return exactly one result/error with the original RPC ID. | + +Standard custom method names start with `_`; advertise negotiated extensions in +capability `_meta`. Unknown requests receive `-32601`; unknown notifications get +no RPC response. Unknown metadata must not become executable instructions. +[ACP extensibility](https://agentclientprotocol.com/protocol/v1/extensibility). + +Implement a small registry inside `opensymphony-acp`, using the SDK's existing +typed handlers and raw dispatch hooks. Each registered extension declares its +namespace, supported contract versions, direction, capability predicate, +method names, parameter/result validation, timeout, and effect/permission policy. +Enable it only for the selected profile and supported peer contract. Peer +advertisement means support, not authorization. If an agent does not advertise a +vendor capability, require an explicit version-tested profile rather than +guessing from its name. + +Use declarative descriptors for bounded JSON request/response operations that +need no domain interpretation. Add Rust handlers for callbacks, stateful flows, +or meaningful projection into approvals, questions, plans, artifacts, and +telemetry. No dynamic library loader, embedded scripting language, or general +plugin runtime is needed. + +Expose enabled outbound operations through one proposed typed operator action, +`HarnessOperation { run_id, operation_id, arguments }`, using existing action +authorization and receipts. Resolve `operation_id` in the enabled registry; +callers cannot supply an arbitrary wire method or session ID. The handler owns +target binding and request validation. Return a structured result or an artifact +reference. A timeout on a state-changing operation means outcome unknown, not +permission to repeat it; only documented idempotent operations may be retried. +Lifecycle-changing extensions still report through scheduler-owned commands. + +### Concrete vendor handling + +Cursor is a useful first extension qualification target. Its documentation lists +blocking `cursor/ask_question` and `cursor/create_plan`, plus notification methods +for todos, tasks, and generated images. The documented names omit the standard +extension prefix. Support those exact names through a Cursor-specific +compatibility registration after checking captured frames; do not rename their +wire methods or weaken generic method validation. Classify each actual frame by +the presence of an RPC ID, including `0`, because request and notification +behavior must follow the wire envelope. +[Cursor ACP](https://cursor.com/docs/cli/acp). + +Map questions to input requests and plans to plan approval; map known +notifications to existing activity/artifact projections. A received subagent +notification is evidence about work, not authority to start an OpenSymphony issue. +Generic unknown-request handling must never hang the agent or auto-approve it. + +Devin CLI should be another profile of the same `acp` adapter. Keep its identity +and lifecycle separate from a Devin cloud API adapter. Qualify actual CLI versions +and extension frames before documenting operation names or capabilities. Secret +management, deployments, archival, and browser attachment need individual +contracts and policy; an extension passthrough cannot establish their semantics. + +For Exo, a future ACP facade can reuse its native execution model and expose +selected extensions. OpenSymphony consumes the published facade contract; +Exo-specific implementation hooks and EBO instrumentation remain separate work. + +## Events, capabilities, and evidence + +Use one ordered application queue for notifications, response markers, and +closure. Process prior updates before publishing a completed turn. Callback +handlers enqueue work and return promptly; permission waits and terminal waits +must not block RPC dispatch. Set explicit frame, queue, outstanding-request, +terminal-output, and artifact limits. On saturation, terminate with a visible +diagnostic rather than silently losing control or evidence messages. Ensure +cancellation can still be serviced under output load. + +Preserve a private, redacted source envelope before typed normalization, including +unknown variants. Record direction, observed timestamp, connection generation, +arrival sequence, session/run binding, and replay/live origin. Native timestamps +or identifiers are separate optional fields; do not invent them. Redaction and +retention apply to stderr and extension data as well as standard frames. + +Project recognized text, thought, tool, plan, usage, and artifact updates through +existing worker/control-plane surfaces. Unknown data remains inspectable without +affecting scheduling. Tool updates are partial updates to an existing tool ID; +missing values must not erase prior state. Keep cumulative session usage, +current-context occupancy, and per-turn token usage distinct. Do not populate +input/output counters from a context-size estimate or count replay twice. + +Keep the existing static `HarnessCapability` for adapter-level support. Add +profile availability and per-run negotiated capabilities so clients can +distinguish compiled support, a configured runnable profile, and an active +session's effective features. Compute effective support from the intersection of +adapter implementation, peer advertisement, and local policy. Availability +before launch should mean configured/preflight-ready, not authenticated proof. + +Add optional protocol provenance fields without changing the meaning of existing +`transport` fields: semantic protocol/version, RPC envelope, encoding, framing, +and carrier. Extension capabilities identify enabled operations and their schema +versions. Keep executable paths, environment values, auth metadata, and private +payloads out of public discovery. Update Rust/TypeScript DTOs and round-trip +tests together. + +Do not map `session/resume` directly to the existing pause/resume buttons: session +restoration and pausing live execution are different capabilities. Similarly, +history loading is not cursor replay, and captured ACP frames are not full native +harness evidence. EBO can ingest them with explicit source/fidelity metadata; +this adapter cannot recover native behavior absent from the ACP stream. + +## Implementation sequence and acceptance + +M12.99 delivers an operational runtime adapter, including production routing and +operator responses. M13 builds multi-harness IDE attachment on that foundation. +The authoritative task graph and Linear mappings are in +[the ACP runtime and IDE package](../tasks/acp-runtime-ide-task-package.yaml). + +| Task | Deliverable and acceptance gate | +| --- | --- | +| OSYM-900 | Typed profiles and executable SDK-backed v1 client; prompt, callbacks, ordered updates, cancel and teardown. | +| OSYM-901 | Host-owned live sessions, durable identity, retention, control/event seam and capability-gated recovery. | +| OSYM-902 | Actual opensymphony run routing, workers, hooks, memory, manifests, interrupts and public capabilities. | +| OSYM-903 | Filesystem/terminal callbacks, model/config selection and scoped MCP setup. | +| OSYM-904 | Worker-to-operator-to-worker permissions/input round trip, gateway/UI controls and deadlines. | +| OSYM-905 | Bidirectional extensions, registered outbound operator actions and verified vendor handling. | +| OSYM-906 | Adversarial conformance, two independent live harnesses and implemented-contract documentation. | + +M13 adds shared attachment, exclusive control handoff, the ACP IDE bridge, editor +setup, desktop launch and qualification before switching the default debug UX. +The bridge consumes the ordered ACP source stream, while normalized worker events +remain the scheduler/dashboard projection. Host-owned filesystem, terminals, +environment and memory grants remain stable when an editor attaches. + +Required checks for the implementation: + +- Fake peer interleaves notifications, responses, permission requests, and cancel; + includes string/zero IDs, malformed/oversized frames, unexpected EOF, unknown + methods/variants, output floods, and an unresponsive child. No deadlocks or + false completion/cancel acknowledgements. +- Two issue workers cannot cross-route session IDs, terminal handles, approvals, + or extension results. Callback path tests cover traversal, symlink escape, + nonexistent write targets, and limits. +- Recovery covers new/load/resume, unsupported persistence, history replay, + profile changes, credential/grant changes, and crashes before/after possible + prompt submission. No automatic ambiguous prompt replay or early cleanup. +- Extension tests cover incoming/outgoing requests, notifications, `_meta`, + supported/unsupported versions, denied operations, unknown methods, stale + responses, malformed results, and timeout without unsafe automatic retry. +- Capability discovery includes schema round trips, gateway endpoint behavior, + adapter-boundary tests, and Rust/TypeScript parity. +- Live runs with pinned agent versions prove a workspace edit, permission round + trip, cancellation, restart behavior, and at least one vendor extension. Use + Cursor plus a structurally different agent such as DeepSeek Harness or Devin; + record versions, actual capabilities, source frames, outcomes, and limitations. + Fake-peer tests alone do not qualify a real harness. + +DeepSeek's published ACP package is a useful recovery contrast: it supports +persistent sessions while explicitly excluding transcript replay and several +other optional surfaces. +[Pinned DeepSeek ACP contract](https://github.com/deepseek-ai/deepseek-harness/blob/ddefc45fbc7f8e46dd73185e68295696d1297887/packages/acp/acp/README.md). + +Implementation updates belong in the workflow resolver and central config, +gateway capability/approval/action DTOs, scheduler worker messages, CLI execution +backend, workspace/session persistence, and the new ACP module. Keep shared +manifest changes additive and compatible with existing OpenHands/Codex state. +Do not require a general harness-framework rewrite before the first working run. + +Update `docs/harness-adapter-compatibility.md`, `docs/configuration.md`, +`docs/architecture.md`, `docs/operations.md`, `docs/workspace-and-lifecycle.md`, +`docs/testing-and-operations.md`, and `docs/sources.md` with the implemented +contract and validation evidence. Milestone sequencing is recorded in +`docs/implementation-plan.md`. + +Protocol reference baseline: +[ACP schema/documentation commit](https://github.com/agentclientprotocol/agent-client-protocol/tree/b9d6aca6757d0f5b6e435cad54f9f04657aa9802). +Orchestration authority: +[upstream Symphony specification](https://github.com/openai/symphony/blob/be10a1b79df723d6d7612b5651c8522704dafb2e/SPEC.md). diff --git a/docs/specs/opensymphony-acp-debugging-spec.md b/docs/specs/opensymphony-acp-debugging-spec.md index fa85b2642..6958616ef 100644 --- a/docs/specs/opensymphony-acp-debugging-spec.md +++ b/docs/specs/opensymphony-acp-debugging-spec.md @@ -1,832 +1,255 @@ -# OpenSymphony ACP Debugging Integration for Zed - -Date: 2026-06-14 - -## Purpose - -Define the implementation plan for exposing OpenSymphony issue debug sessions through Zed's ACP external-agent interface while preserving the existing OpenHands conversation model and current OpenSymphony workspace structure. - -The goal is to make Zed the code, diff, and manual intervention surface for a selected issue workspace, while OpenSymphony remains the authority for resolving issue workspaces, conversation manifests, OpenHands stores, runtime streams, and debug turns. - -## Current authoritative state - -This spec is grounded in the current OpenSymphony repository behavior and the observed local runtime layout. - -### User-level runtime layout - -OpenSymphony-managed local state currently lives under the user-level directory: - -```text -~/.opensymphony/ - openhands-server/ - quarantine/ - workspaces/ -``` - -Managed OpenHands tooling lives under: - -```text -~/.opensymphony/openhands-server/ -``` - -Existing local installations may have a flat OpenHands conversation store: - -```text -~/.opensymphony/openhands-server/workspace/conversations// -``` - -The source code names this flat store the `Legacy` store. It remains a supported lookup source. - -The source code also defines repo-scoped managed stores: - -```text -~/.opensymphony/openhands-server/workspace/conversations/repos//active/ -~/.opensymphony/openhands-server/workspace/conversations/repos//archived/ -``` - -The repo-scoped stores may be absent in existing installations until OpenSymphony creates or migrates them. They are part of the managed-store model, not a universal assumption about every local machine. - -### Issue workspace layout - -Each issue has a deterministic workspace under the configured workspace root, commonly: - -```text -~/.opensymphony/workspaces// -``` - -The issue workspace contains workspace-local OpenSymphony metadata: - -```text -~/.opensymphony/workspaces//.opensymphony/ - issue.json - conversation.json - openhands/ - create-conversation-request.json - logs/ - generated/ - prompts/ - runs/ -``` - -The important manifest for ACP debug attachment is: - -```text -~/.opensymphony/workspaces//.opensymphony/conversation.json -``` - -That manifest contains the durable OpenHands conversation identity and launch context, including: - -```text -issue_id -identifier -conversation_id -server_base_url -persistence_dir -created_at -last_attached_at -fresh_conversation -reset_reason -runtime_contract_version -``` - -### Existing debug behavior - -`opensymphony debug ` currently: - -1. Resolves runtime configuration. -2. Builds a `WorkspaceManager`. -3. Finds the issue workspace by issue reference. -4. Loads the workspace-local issue manifest. -5. Loads the workspace-local `.opensymphony/conversation.json` manifest. -6. Parses the OpenHands `conversation_id`. -7. Resolves the OpenHands conversation store through active, archived, or legacy paths. -8. Builds or reuses an OpenHands client and local server supervisor. -9. Attaches a runtime event stream to the conversation. -10. Enters an interactive terminal debug loop. - -The ACP implementation should reuse this resolution and attachment behavior. It should avoid introducing a new debug manifest or a parallel runtime state model. - -## Desired UX - -### Zed as the IDE debug surface - -The Tauri app should expose a debug action for an issue. For example: - -```text -Debug in Zed -``` - -The action should: - -1. Resolve the selected issue key to its exact OpenSymphony issue workspace path. -2. Launch Zed on that exact workspace path. -3. Keep the rich OpenSymphony orchestration and visualization UI in Tauri. -4. Let the operator start the configured OpenSymphony Debug external agent in Zed. - -Expected launch shape: - -```bash -zed -n ~/.opensymphony/workspaces/COE-370 -``` - -At MVP scope, the operator starts the OpenSymphony Debug external agent from Zed's Agent Panel, Threads Sidebar, or a user-configured Zed keybinding. The spec does not assume a documented Zed CLI or URI API for opening a workspace and auto-starting a particular external-agent thread. - -### Zed ACP process model - -Zed runs a statically configured ACP external-agent command. The command is independent of the specific issue key. - -Recommended one-time Zed configuration: - -```json -{ - "agent_servers": { - "opensymphony-debug": { - "type": "custom", - "command": "opensymphony", - "args": ["debug", "--acp-stdio"], - "env": {} - } - } -} -``` - -When the operator starts the OpenSymphony Debug external agent in Zed, Zed spawns: - -```bash -opensymphony debug --acp-stdio -``` - -Zed then sends ACP JSON-RPC messages over the subprocess stdin/stdout stream. The issue workspace is communicated through the ACP `session/new` request, not through the process command line. - -Expected ACP request shape: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "session/new", - "params": { - "cwd": "/Users/magos/.opensymphony/workspaces/COE-370", - "mcpServers": [] - } -} -``` - -The ACP server mode must treat `params.cwd` as the authoritative workspace selection input. - -## Command surface - -Keep ACP debugging inside the existing `debug` command family. - -### `opensymphony debug ` - -Default user-facing debug entrypoint. - -Initial behavior: - -1. Resolve `` to the exact issue workspace. -2. Launch or prepare the preferred IDE debug experience, initially Zed. -3. Print concise instructions if the IDE cannot be launched or if the operator must start the Zed external agent manually. - -This command should become the primary operator-facing debug UX. - -### `opensymphony debug --cli` - -Compatibility mode for the existing terminal interactive debug loop. - -This path should preserve current behavior as closely as possible: - -1. Resolve issue workspace. -2. Load `.opensymphony/conversation.json`. -3. Attach to OpenHands. -4. Print recent history. -5. Accept terminal prompts. -6. Send user messages and run the OpenHands conversation. - -### `opensymphony debug --acp-stdio` - -Noninteractive ACP server mode for Zed and other ACP clients. - -Behavior: - -1. Start an ACP JSON-RPC server over stdio. -2. Accept `session/new` with a `cwd` parameter. -3. Resolve `cwd` as an exact OpenSymphony issue workspace root. -4. Attach to the workspace's OpenHands conversation. -5. Serve ACP prompts, events, and session teardown. - -Rules: - -1. No issue key is required in this mode. -2. Human-readable protocol output must not be written to stdout. -3. The process exits when the ACP client closes the stream or terminates the session. -4. This mode should be hidden from ordinary help text unless there is already a convention for advanced flags. - -## Workspace selection policy - -The ACP handler must use strict workspace selection. - -### Valid ACP `cwd` - -A valid `cwd` is the exact issue workspace root: - -```text -~/.opensymphony/workspaces/ -``` - -The following files must exist: - -```text -cwd/.opensymphony/issue.json -cwd/.opensymphony/conversation.json -``` - -`conversation.json` is the source for `conversation_id` and runtime attach context. - -### Invalid ACP `cwd` - -Reject the session when `cwd` is any of the following: - -```text -~/.opensymphony/workspaces/ -~/.opensymphony/workspaces//some/nested/path -/path/to/target-repo -~/.opensymphony/openhands-server/workspace/conversations/ -~/.opensymphony/openhands-server/workspace/conversations/repos//active/ -~/.opensymphony/openhands-server/workspace/conversations/repos//archived/ -``` - -The ACP handler should return an actionable error message such as: - -```text -OpenSymphony Debug must be started from an exact issue workspace root. -Open ~/.opensymphony/workspaces/COE-370 in Zed, then start the OpenSymphony Debug agent again. -``` - -### No fuzzy workspace resolution - -The ACP handler should not walk upward to find `.opensymphony/conversation.json`. - -The ACP handler should not infer an issue workspace from an OpenHands conversation-store directory. - -The ACP handler should not scan all workspaces to guess a conversation for the current project. - -## OpenHands conversation store resolution - -Workspace resolution and OpenHands store resolution are separate steps. - -Step 1 resolves the OpenSymphony issue workspace: - -```text -ACP session/new.cwd - → cwd/.opensymphony/conversation.json - → conversation_id -``` - -Step 2 resolves the durable OpenHands conversation storage: - -```text -conversation_id - → repo-scoped active store - → repo-scoped archived store - → legacy flat store -``` - -This should reuse the existing `OpenHandsConversationStorePaths` logic and the existing debug preparation behavior. - -The legacy flat store must remain accepted as a valid OpenHands conversation source: - -```text -~/.opensymphony/openhands-server/workspace/conversations// -``` - -The ACP attachment should use the same attach and rehydrate behavior as the existing terminal debug path. - -## ACP session semantics - -ACP session state is an attachment to a durable OpenHands conversation. - -Internal mapping: - -```text -ACP session id - → issue key - → issue workspace path - → conversation manifest - → OpenHands conversation_id - → RuntimeEventStream - → optional LocalServerSupervisor ownership -``` - -The ACP session id should be generated by OpenSymphony. It can include the issue key for readability, but it should remain an ACP attachment id rather than a raw OpenHands conversation id. - -Recommended shape: - -```text -opensymphony-debug:: -``` - -## ACP method behavior - -### Initialize - -Expose OpenSymphony as a debug-capable ACP external agent. - -The agent name should be stable, for example: - -```text -OpenSymphony Debug -``` - -The initial implementation should advertise only the capabilities it implements. - -### `session/new` - -Input: - -```text -params.cwd -``` - -Behavior: - -1. Validate that `cwd` is an exact issue workspace root. -2. Load `cwd/.opensymphony/conversation.json`. -3. Load `cwd/.opensymphony/issue.json` if needed for issue state and display. -4. Resolve runtime config from the target repo context using the same approach as current debug behavior. -5. Resolve OpenHands store kind through existing active, archived, and legacy logic. -6. Build or reuse the OpenHands client and local server supervisor. -7. Attach a `RuntimeEventStream` to the existing OpenHands conversation. -8. If the conversation has a turn in progress, stream status and wait until the turn is safe for user input, matching current terminal debug behavior. -9. Return an ACP session id. -10. Send an initial assistant message summarizing the attached issue, workspace, conversation id, and current execution status. - -Initial message example: - -```text -Attached to OpenSymphony issue COE-370. -Workspace: ~/.opensymphony/workspaces/COE-370 -Conversation: 1f3e... -Status: idle - -You can review code and diffs in this Zed workspace. Send a message here to continue the existing OpenHands conversation. -``` - -### `session/prompt` - -Input: - -```text -ACP session id -user message -``` - -Behavior: - -1. Verify the ACP session is attached. -2. If an OpenHands turn is already in progress, wait for the current turn to stop using the existing debug wait behavior. -3. Send the user message to the OpenHands conversation through `OpenHandsClient::send_message`. -4. Invoke `OpenHandsClient::run_conversation`. -5. Stream normalized assistant, action, and observation events back to Zed. -6. End the ACP prompt response when the OpenHands turn reaches a terminal or idle state. - -The existing terminal debug function already performs the core sequence: - -```text -send_message(conversation_id, user_text) -run_conversation(conversation_id) -wait_for_turn_terminal(...) -``` - -The ACP implementation should extract this into reusable debug-turn logic instead of duplicating protocol-specific code paths. - -### `session/close` - -Behavior: - -1. Close the runtime event stream. -2. Release any ACP session subscriptions. -3. Drop or stop a local supervisor only when this ACP process owns the supervisor. -4. Leave the durable OpenHands conversation intact. -5. Leave workspace files intact. -6. Leave `.opensymphony/conversation.json` intact. -7. Leave repo memory intact. - -Closing an ACP session is a detach operation for this integration. - -### Optional methods - -The MVP does not require `session/list`, `session/load`, or `session/resume`. - -Those methods can be added after the cwd-based attach path is stable. - -A future `session/list` could expose known issue workspaces as importable sessions, but it should not be part of the first implementation slice. - -## Event mapping - -OpenSymphony should map existing normalized OpenHands runtime events into ACP updates that Zed can display in the agent thread. - -Recommended role mapping: - -```text -OpenHands user message → ACP user message echo or prompt boundary -OpenHands assistant text → ACP assistant message/update -OpenHands action/tool event → ACP action or structured status message -OpenHands observation → ACP observation or structured status message -Execution status changes → ACP progress/status update -Errors → ACP error update with concise recovery guidance -``` - -The event stream should preserve useful debug context: - -```text -event id -timestamp -role or event kind -summary text -raw details when safe and useful -execution status -``` - -Sensitive values should follow existing secret-redaction policy. - -## Refactor plan - -The current terminal debug implementation should be split into reusable layers. - -### `debug_session` core layer - -Extract reusable primitives from the current debug code: - -```text -resolve_debug_runtime_config(args or cwd) -resolve_issue_workspace(issue key) -load_issue_manifest(workspace) -load_conversation_manifest(workspace) -prepare_debug_conversation_store(runtime, conversation_id, issue_manifest) -build_debug_client(runtime, store_kind) -attach_or_rehydrate_stream(...) -wait_for_turn_to_stop(...) -run_debug_turn(...) -``` - -### `DebugAttachment` - -Introduce an internal struct for active debug attachment state: - -```rust -struct DebugAttachment { - session_id: String, - issue_key: String, - workspace_path: PathBuf, - conversation_id: Uuid, - client: OpenHandsClient, - stream: RuntimeEventStream, - supervisor: Option, -} -``` - -This struct should support: - -```text -send_prompt(...) -stream_events(...) -close(...) -status(...) -``` - -### CLI terminal layer - -The `--cli` path should use `DebugAttachment` and then enter the existing terminal read/eval loop. - -### ACP stdio layer - -The `--acp-stdio` path should use `DebugAttachment` and serve JSON-RPC over stdio. - -It should contain only protocol adaptation logic: - -```text -ACP request parsing -ACP response serialization -ACP session lifecycle -ACP update emission -mapping DebugAttachment events to ACP messages -``` - -## Tauri integration - -The Tauri app should call an OpenSymphony API or internal command to resolve an issue key to the exact issue workspace path. - -Inputs: - -```text -issue key -preferred editor = zed -``` - -Output: - -```text -workspace path -launch command -operator instruction text, when needed -``` - -For Zed: - -```bash -zed -n -``` - -The app should not create per-issue Zed agent configurations. - -The app should not write additional workspace debug manifests. - -The app may provide a one-time setup UX for the static Zed external-agent configuration. - -## Zed integration - -Zed should have one static OpenSymphony Debug external-agent configuration. - -The configured command should be: - -```bash -opensymphony debug --acp-stdio -``` - -The operator opens the issue workspace in Zed and starts the OpenSymphony Debug agent. - -The ACP `cwd` supplied by Zed is expected to equal the opened project root. The OpenSymphony handler validates this by requiring `cwd/.opensymphony/conversation.json` and `cwd/.opensymphony/issue.json`. - -## Concurrency policy - -MVP policy: - -```text -one active ACP debug session per spawned opensymphony debug --acp-stdio process -``` - -If Zed starts multiple external-agent processes, each process may attach to one workspace. - -If the same process receives a second `session/new` while a session is active, return an ACP error: - -```text -This OpenSymphony Debug ACP process already has an active session. Close the current debug thread before starting another one. -``` - -This policy can be relaxed later if Zed UX and ACP process lifecycle behavior make multiplexing valuable. - -## Failure behavior - -### Invalid cwd - -Return a user-facing ACP error with the expected path shape. - -### Missing conversation manifest - -Return: - -```text -No OpenSymphony conversation manifest was found at cwd/.opensymphony/conversation.json. -Open an exact OpenSymphony issue workspace in Zed, then start OpenSymphony Debug again. -``` - -### Invalid conversation id - -Return a manifest validation error naming the manifest path and invalid field. - -### OpenHands conversation missing - -Use the existing attach-or-rehydrate behavior from terminal debug. - -If the existing code would rehydrate, ACP should rehydrate. - -If the existing code would report an archived-store mismatch or unavailable conversation, ACP should report the same condition in a concise agent-thread message. - -### Active turn already running - -Mirror current terminal debug behavior: - -1. Inform the user that a turn is already in progress. -2. Wait until the turn stops. -3. Continue accepting input if the wait succeeds. -4. Report timeout without detaching if the wait times out. - -### Existing OpenHands server on same port with different store - -Surface the same operational guidance as current debug behavior: - -```text -Stop the existing OpenHands server or free the port, then retry the debug session. -``` - -## Safety and state boundaries - -The ACP debug integration must preserve the existing ownership model. - -OpenSymphony owns: - -```text -issue workspace resolution -conversation manifest interpretation -OpenHands store selection -runtime stream attachment -conversation turn execution -local server supervision when applicable -``` - -Zed owns: - -```text -code inspection -manual file edits -diff review -agent thread UI -ACP client process management -``` - -Tauri owns: - -```text -orchestration cockpit -visual trace UI -debug action routing -Zed launch action -one-time integration setup UX, if implemented -``` - -The ACP adapter must not: - -```text -create .opensymphony/debug.json -write per-issue Zed agent configs -delete OpenHands conversations during session close -delete issue workspaces during session close -alter repo memory during session close -infer workspaces from nested paths or parent paths -``` - -## Test plan - -### Unit tests - -Add tests for: - -```text -valid cwd with issue.json and conversation.json -invalid parent workspace root -invalid nested subdirectory -invalid target repo root -invalid OpenHands conversation store path -conversation manifest parsing -invalid conversation id -legacy flat store lookup -active store lookup -archived store lookup -single active ACP session enforcement -``` - -### Integration tests - -Add an integration test using the existing fake OpenHands server and a minimal ACP stdio client harness. - -Test flow: - -```text -create fixture issue workspace -write .opensymphony/issue.json -write .opensymphony/conversation.json -create fake OpenHands conversation state -spawn opensymphony debug --acp-stdio -send initialize -send session/new with exact cwd -assert attached response -send session/prompt -assert OpenHands send_message and run_conversation were called -assert assistant/event updates are returned -send session/close -assert stream closes and workspace remains intact -``` - -### CLI regression tests - -Add tests for: - -```text -opensymphony debug COE-370 --cli routes to terminal debug path -opensymphony debug --acp-stdio starts ACP mode without requiring issue key -opensymphony debug --acp-stdio COE-370 is rejected or documented explicitly -opensymphony debug COE-370 resolves workspace for default IDE debug path -``` - -## Acceptance criteria - -1. A single static Zed external-agent config can start OpenSymphony Debug. -2. Tauri can open Zed on an exact issue workspace. -3. Starting the OpenSymphony Debug agent in that Zed workspace attaches to the existing OpenHands conversation for that issue. -4. The ACP handler obtains the issue selection from `session/new.cwd`. -5. The ACP handler requires `cwd/.opensymphony/conversation.json` and `cwd/.opensymphony/issue.json`. -6. The legacy flat OpenHands conversation store remains supported. -7. Active and archived repo-scoped stores remain supported. -8. `opensymphony debug --cli` preserves the current terminal debug workflow. -9. `opensymphony debug --acp-stdio` writes only ACP protocol messages to stdout. -10. ACP session close detaches from the debug stream without deleting durable state. -11. No `.opensymphony/debug.json` or equivalent extra manifest is introduced. -12. No per-conversation Zed `agent_servers` entries are created. -13. One active ACP debug session per spawned ACP process is enforced for MVP. -14. Invalid cwd produces a concise, actionable error. - -## Implementation slices - -### Slice 1: Debug core refactor - -Extract reusable resolution, attach, and turn execution logic from the current terminal debug command. - -Deliverables: - -```text -DebugAttachment core type -shared attach path -shared run-turn path -existing CLI behavior preserved behind --cli -``` - -### Slice 2: ACP stdio server mode - -Add `opensymphony debug --acp-stdio`. - -Deliverables: - -```text -ACP initialize -ACP session/new with strict cwd validation -ACP session/prompt -ACP session/close -single-session enforcement -``` - -### Slice 3: Zed static integration guidance - -Add documentation or app onboarding content for the one-time Zed external-agent config. - -Deliverables: - -```text -Zed settings snippet -usage instructions -failure guidance for invalid cwd -``` - -### Slice 4: Tauri debug launch - -Wire the Tauri issue debug button to resolve and open the exact issue workspace in Zed. - -Deliverables: - -```text -resolve issue key to workspace path -launch zed -n -show instruction to start OpenSymphony Debug agent inside Zed -``` - -### Slice 5: Default debug UX transition - -Change the default `opensymphony debug ` behavior to the IDE-oriented flow after slices 1 through 4 are stable. - -Deliverables: - -```text -plain debug opens/prepares IDE debug UX ---cli preserves terminal behavior -clear fallback messaging when Zed or Tauri is unavailable -``` - -## Source grounding - -The following current source locations are the grounding points for this spec: - -```text -crates/opensymphony-workspace/src/models.rs - WorkspaceHandle metadata paths - conversation_manifest_path() - ConversationManifest fields - -crates/opensymphony-openhands/src/conversation_store.rs - OPENHANDS_CONVERSATIONS_PATH_ENV - ConversationStoreKind: Active, Archived, Legacy - OpenHandsConversationStorePaths - active, archived, legacy store layout - locate_conversation lookup order - compact/raw UUID directory handling - -crates/opensymphony-cli/src/debug_session.rs - current debug command args - runtime config resolution - workspace lookup - conversation manifest loading - OpenHands store preparation - debug client construction - attach_or_rehydrate_stream - interactive debug loop - send_message plus run_conversation debug turn behavior -``` +# OpenSymphony multi-harness ACP debugging and IDE attachment + +Status: implementation specification for M13. Date: 2026-09-22. +Foundation: [M12.99 ACP runtime adapter](acp-harness-adapter.md). +This document specifies behavior to implement; it does not claim these paths are operational. + +## Purpose and acceptance boundary + +Attach an ACP-capable IDE to the same agent session OpenSymphony uses for an +issue. Every configured harness profile supported by the ACP runtime adapter is +a first-class attachment target. Adding a compliant profile within the supported +version, transport and capability matrix requires no provider-specific debug code. + +Zed is the initial qualified editor. OpenSymphony owns workspace resolution, +runtime identity, execution facilities, control arbitration and scheduling. The +IDE provides code inspection, manual editing, diffs and an agent interaction UI. +OpenHands native debug and Codex resume/unarchive/app routes retain their existing +contracts. Their IDE capabilities are advertised individually; ACP profile support +is a core M13 requirement. + +## Runtime and protocol ownership + +```mermaid +flowchart TB + I[IDE: ACP client] <--> B[OpenSymphony debug: ACP server] + B <--> C[Control-plane commands and ordered events] + C <--> O[Host-owned issue runtime session] + S[Orchestrator scheduler] <--> O + O <--> A[ACP runtime client] + A <--> H[Configured ACP harness] + O <--> N[Native adapter attachment where supported] +``` + +M12.99 implements the ACP client, executable routing and host-owned session. +M13 implements the IDE-facing server, attachment resolution and exclusive writer +handoff. The two ACP legs have separate request IDs, session IDs, negotiated +versions and capability sets. They share one underlying runtime session. + +The host owns each issue's supervised agent process and connection across worker +attempts and IDE attachments. The debug stdio process reaches that owner through +the existing control-plane command/event boundary. It never starts a competing +agent to attach to a live session. Reuse existing channels and persistence; +there is no separate scheduling service or debug session database. + +A host retention policy keeps eligible idle sessions alive within bounded resource +limits. Attachments and active work pin a session against normal eviction. This +allows live attachment even when a harness has no persistent-session support. +After owner/process loss, restoration depends on negotiated load/resume support. +A recorded transcript is inspectable evidence, not restored agent context. + +The normal attached-host path requires the owning host to be reachable. An +unavailable owner is an actionable state. Native offline recovery remains subject +to its existing identity validation; generic offline ACP ownership takeover is +outside this milestone. + +## Verified workspace and session binding + +`session/new.params.cwd` selects the exact issue workspace. The shared resolver +validates `.opensymphony/issue.json`, `.opensymphony/conversation.json`, the runtime +envelope, configured repository/checkout, run/generation and persisted +harness/profile identity. File existence alone is insufficient. + +Reject parent workspace roots, nested directories, ordinary target-repository +roots, runtime conversation-store directories, symlink escapes, stale generations +and mismatched envelopes. Do not search upward or guess a session across +workspaces. Return the expected exact workspace and an actionable recovery step. + +The attachment stores an opaque native session ID and a host session reference. +An OpenHands UUID is an adapter detail. The outer ACP session ID identifies this +IDE attachment, and is distinct from native identity and the writer lease. +Profile edits cannot redirect an existing session to the current default agent. + +Native compatibility includes: + +- OpenHands active, archived and legacy flat conversation-store lookup through + `OpenHandsConversationStorePaths`, existing readiness/reconciliation and + rehydration behavior, and SDK agent-server REST/WebSocket contracts. +- Codex manifest-bound thread identity, archived-thread unarchive, terminal + `codex resume`, and explicit `--app` deep links. Preserve these routes without + requiring a native Codex-to-ACP bridge in this milestone. + +Use existing manifests and memory grants. No `.opensymphony/debug.json`, +per-issue IDE configuration or duplicate durable conversation record is needed. + +## Scheduler and IDE control + +Observation does not grant permission to prompt. A single orchestrator-owned +writer lease covers prompts, configuration mutations, lifecycle operations and +response ownership for a bound runtime session. + +1. Attach and subscribe to status/events using the verified identity. +2. Request control through the orchestrator. Hold new issue dispatch and retries + before waiting for the running turn to settle or cancelling it explicitly. +3. Resolve or transfer pending interactions under a defined single-responder + policy. Grant the IDE a generation-fenced writer only after quiescence is + established. A snapshot saying idle is insufficient without the scheduler hold. +4. Accept IDE prompts and allowed operations only while that lease is current. + Route approvals/questions through the same validation and response machinery + used by operator clients. Reject duplicate and stale decisions. +5. Release control after debug-owned work settles and pending requests are + resolved. The orchestrator decides whether and when normal scheduling resumes. + +Expose observing, acquiring, controlling, waiting-for-input, releasing and +uncertain states through existing control-plane DTOs. The UI cannot mutate +scheduler internals. Other issues continue scheduling normally. + +Concurrent editor processes cannot acquire simultaneous writers. A lost editor, +expired lease, failed cancellation or host restart cannot authorize new execution +while the old execution may still be active. Use existing runtime-generation and +cleanup fences; deadlines produce explicit outcomes, not assumed success. +Manual workspace edits remain part of the trusted local workflow; the writer +lease arbitrates agent execution, not an OS filesystem lock on the editor. + +## ACP bridge behavior + +Use the official Rust SDK server role and the same pinned schema baseline as the +runtime adapter. Initial support is ACP v1 over stdio, with one active attachment +per bridge process. Human diagnostics use stderr; stdout contains protocol frames. + +| Method/surface | Required behavior | +| --- | --- | +| `initialize` | Advertise a conservative implemented capability set before cwd identifies a profile; negotiate independently from the downstream harness. | +| `session/new` | Verify exact cwd and runtime binding, attach to the existing owner, return an attachment ID and accurate session options/status. | +| `session/prompt` | Acquire/validate control, translate supported content to the bound session, stream ordered source updates, and finish from that prompt's actual terminal result. | +| `session/cancel` | Cancel attachment-owned work through the runtime owner; remain responsive while callbacks or output are pending. | +| `session/close`, when advertised | Cancel ongoing work belonging to this attachment, settle pending requests, and free its subscriptions/control resources. Preserve durable workspace and native session identity. | +| Permission/input requests | Map request/session IDs across legs and deliver one validated decision to the original callback; use the declared host-operator fallback when the IDE cannot respond. | +| Config/modes and extensions | Allow only negotiated and policy-enabled operations through the owner; publish accurate per-session choices and results. | + +Optional outer `session/list`, `session/load` and `session/resume` are unadvertised +until implemented. Restoring an underlying harness session is a separate concern +from offering editor history APIs. Unsupported features return explicit errors. + +`session/close` is not a blanket detach-only operation: the ACP contract requires +cancelling ongoing session work before freeing resources. Apply that obligation +to attachment-owned work. Do not cancel unrelated scheduler work observed by a +read-only attachment or delete native conversations/workspaces. +On EOF/editor death, run the documented cancellation/release policy; retain an +uncertain fence if stopping cannot be proven. Never treat EOF as acknowledgement. +[ACP session setup](https://agentclientprotocol.com/protocol/v1/session-setup), +[ACP prompt and cancellation](https://agentclientprotocol.com/protocol/v1/prompt-turn). + +## Fidelity, capabilities and extensions + +For ACP-backed sessions, forward typed/source ACP events and permitted metadata +from the runtime owner's ordered queue. Keep domain normalization as a parallel +projection for scheduling and dashboards. Reconstructing IDE traffic from +`WorkerUpdate` summaries would discard tool/config/extension semantics. +Native adapters map their available events at their own boundary and report +unsupported fidelity explicitly. + +Separate outer/inner RPC correlation and rewrite only identity fields whose +contract requires it. Preserve content blocks, partial tool updates, plans, +config changes and allowed `_meta`. IDs can be strings or numbers, including +zero. Tag replay segments and avoid duplicating historical tools or usage. +Bound queues/frames and report overflow; control/cancel processing must remain +responsive during output floods. + +Effective IDE support is the intersection of bridge implementation, bound +harness capabilities, editor capabilities and local policy. Late attachment +cannot add client callbacks to an already initialized harness connection. +Connection-level initialize capabilities therefore use a conservative common +set; session-specific options and diagnostics describe the selected profile. + +Filesystem access, terminals, environment, scoped memory and MCP execution stay +host-owned. An IDE's `mcpServers` or callbacks cannot silently change an existing +session's environment or tool set. Explicit supported reconfiguration requires +validation, control ownership and truthful capability/state updates. + +Use the M12.99 extension registry for both legs. Forward registered operations +only when the peer contract and policy permit them; preserve approved metadata +without interpreting it as instructions. Blocking extension requests need a +real response route. A missing editor handler uses an explicitly supported +host-operator fallback or a clear error, never a silent stall or auto-approval. +Unknown requests return method-not-found; unknown notifications receive no reply +and remain bounded redacted evidence. + +Cursor-specific non-underscore names belong in its version-tested registry +entry. New vendors use configured ACP profiles; no vendor switch belongs in +IDE attachment code. Capabilities advertised by a peer do not authorize effects. + +## Commands and editor setup + +- `opensymphony debug --acp-stdio`: noninteractive bridge, no issue argument; + the editor supplies cwd through `session/new`. +- `opensymphony debug --cli`: explicit terminal/native debug path, + using shared binding and control arbitration where applicable. +- `opensymphony debug --app`: preserve the supported native app path. +- `opensymphony debug `: capability-appropriate IDE launch or actionable + fallback, enabled only after the multi-harness qualification task passes. + +One static Zed external-agent entry launches `opensymphony` with argv +`["debug", "--acp-stdio"]`. Verify current Zed settings syntax during setup +implementation. The operator opens the exact issue workspace and starts the +OpenSymphony Debug external agent. No per-profile or per-issue editor entries +are required. Other ACP editors can use the same command within the supported +capability contract; actual qualification remains editor-specific. + +The desktop debug action consumes the shared resolver/capabilities, displays +harness/profile and control readiness, and launches `zed -n ` +using an argv vector. It reports missing editor/workspace/owner, unsupported +restoration and busy control with recovery guidance. The operator starts the +agent thread in Zed unless a documented editor API provides that action. +Tauri owns presentation and launch; it does not contain runtime clients. + +## Validation and rollout + +Hermetic tests use a fake outer IDE plus fake inner ACP harness through the +actual runtime host and CLI bridge. They cover two profiles with differing +capabilities, opaque IDs, bidirectional callbacks, extensions, event fidelity, +cancel/close, replay and failures. They do not require Zed in CI. + +The failure matrix includes invalid cwd/binding, missing owner/manifests, +nonpersistent context loss, unsupported restoration/extensions, stale/duplicate +responses, overlapping scheduler prompts, approval during handoff, editor crash, +lease expiry, output saturation, uncertain stop, and cleanup during attachment. +Native regressions cover all OpenHands store forms and Codex unarchive/resume/app. + +Live acceptance requires two independent ACP harness implementations with pinned +versions, both attached through actual Zed to the sessions used by +`opensymphony run`. Demonstrate debug prompts, an operator/callback round trip, +cancellation, release and scheduler continuation. Include a verified vendor +extension or its documented IDE fallback. Record versions, negotiated features, +redacted evidence and limits separately from fake-peer results. + +| Task | Deliverable | +| --- | --- | +| OSYM-840 | Multi-harness attachment core on M12.99 execution/session wiring. | +| OSYM-907 | Orchestrator-owned control transfer, response ownership and fences. | +| OSYM-841 | ACP IDE server/bridge with source fidelity and extension handling. | +| OSYM-842 | Static editor setup and capability-aware operator guidance. | +| OSYM-843 | Desktop launch using shared binding and capability results. | +| OSYM-845 | Multi-harness integration/race tests and real editor qualification, gated on M12.99 qualification. | +| OSYM-844 | Default IDE UX and preserved explicit CLI/native routes, after qualification. | + +The executable dependency graph is in +[the ACP runtime and IDE task package](../tasks/acp-runtime-ide-task-package.yaml). +Update `docs/architecture.md`, `docs/configuration.md`, `docs/operations.md`, +`docs/workspace-and-lifecycle.md`, `docs/testing-and-operations.md`, +`docs/harness-adapter-compatibility.md` and pinned sources with implemented +behavior and evidence in the corresponding implementation changes. + +## Repository grounding + +- `crates/opensymphony-cli/src/debug_session.rs`: current workspace validation, + native OpenHands attachment and Codex debug routing. +- `crates/opensymphony-openhands/src/conversation_store.rs`: store lookup and + active/archive/legacy compatibility. +- `crates/opensymphony-workspace/src/models.rs`: workspace manifests/envelopes. +- `crates/opensymphony-orchestrator/src/scheduler.rs`: worker events, runtime + identity, interrupts, scheduling and cleanup authority. +- `crates/opensymphony-control/`, gateway/schema and shared API clients: + cross-process commands, snapshots, action receipts and event subscriptions. +- [ACP runtime adapter design](acp-harness-adapter.md): pinned protocol/SDK + sources, lifecycle, callback, extension and operator response contracts. diff --git a/docs/tasks/acp-runtime-ide-linear-publish.yaml b/docs/tasks/acp-runtime-ide-linear-publish.yaml new file mode 100644 index 000000000..da318716e --- /dev/null +++ b/docs/tasks/acp-runtime-ide-linear-publish.yaml @@ -0,0 +1,81 @@ +planningWave: acp-runtime-and-ide-attachment +linearProject: e7b957855cb7 +publishedAt: '2026-09-22T05:43:58.212962+00:00' +milestones: + 'M12.99: ACP Harness Runtime Adapter': + milestoneId: ea0aefa3-3507-455e-a682-b912be95898a + name: 'M12.99: ACP Harness Runtime Adapter' + 'M13: ACP Debugging And IDE Attach': + milestoneId: 12eda288-1f93-45db-a31d-43afab515cd8 + name: 'M13: ACP Debugging And IDE Attach' +tasks: + OSYM-900: + issue: COE-608 + issueId: 586ff6c6-36aa-48df-bc36-9d0ee58a942b + url: https://linear.app/trilogy-ai-coe/issue/COE-608/acp-profiles-and-executable-protocol-client + file: docs/tasks/osym-900-acp-profiles-and-executable-protocol-client.md + OSYM-901: + issue: COE-609 + issueId: 7fe1bf06-9b5d-4e12-ba3b-cbac6267249b + url: https://linear.app/trilogy-ai-coe/issue/COE-609/acp-session-ownership-and-durable-recovery + file: docs/tasks/osym-901-acp-session-ownership-and-durable-recovery.md + OSYM-903: + issue: COE-610 + issueId: f9af7cf3-085e-48cd-ad99-c2762a8dee2f + url: https://linear.app/trilogy-ai-coe/issue/COE-610/acp-client-callbacks-and-session-configuration + file: docs/tasks/osym-903-acp-client-callbacks-and-session-configuration.md + OSYM-902: + issue: COE-611 + issueId: 968d5159-633b-4fa7-8295-0f751faba40a + url: https://linear.app/trilogy-ai-coe/issue/COE-611/acp-execution-routing-and-worker-integration + file: docs/tasks/osym-902-acp-execution-routing-and-worker-integration.md + OSYM-840: + issue: COE-455 + issueId: 1c6b02f5-20d3-4a48-8b02-512b43b10bda + url: https://linear.app/trilogy-ai-coe/issue/COE-455/multi-harness-debug-attachment-core + file: docs/tasks/osym-840-debug-attachment-core-refactor.md + OSYM-904: + issue: COE-612 + issueId: 4d69274a-b427-42b8-8af4-80916ab733b8 + url: https://linear.app/trilogy-ai-coe/issue/COE-612/acp-operator-requests-and-response-routing + file: docs/tasks/osym-904-acp-operator-requests-and-response-routing.md + OSYM-905: + issue: COE-613 + issueId: 191f0c79-9679-4533-aee7-b6f43d7b0ee4 + url: https://linear.app/trilogy-ai-coe/issue/COE-613/acp-extensions-and-harness-operations + file: docs/tasks/osym-905-acp-extensions-and-harness-operations.md + OSYM-907: + issue: COE-614 + issueId: 3ae6e5e0-652a-46a4-b347-f42935fab168 + url: https://linear.app/trilogy-ai-coe/issue/COE-614/debug-control-handoff-and-scheduler-fencing + file: docs/tasks/osym-907-debug-control-handoff-and-scheduler-fencing.md + OSYM-841: + issue: COE-457 + issueId: 6144149c-5daf-439f-ae56-68255d72ab71 + url: https://linear.app/trilogy-ai-coe/issue/COE-457/multi-harness-acp-ide-bridge + file: docs/tasks/osym-841-acp-stdio-server-protocol-adapter.md + OSYM-906: + issue: COE-615 + issueId: 91141389-ba7a-4910-b9c3-0b557769b398 + url: https://linear.app/trilogy-ai-coe/issue/COE-615/acp-runtime-conformance-and-live-qualification + file: docs/tasks/osym-906-acp-runtime-conformance-and-live-qualification.md + OSYM-842: + issue: COE-459 + issueId: 412da99d-aac9-4113-9ded-862fe989dc71 + url: https://linear.app/trilogy-ai-coe/issue/COE-459/multi-harness-ide-setup-and-capability-guidance + file: docs/tasks/osym-842-zed-static-agent-configuration-and-setup-ux.md + OSYM-843: + issue: COE-462 + issueId: e35799ff-8aea-44f2-aac5-69195cc72221 + url: https://linear.app/trilogy-ai-coe/issue/COE-462/multi-harness-desktop-debug-in-zed-action + file: docs/tasks/osym-843-tauri-debug-in-zed-launch-action.md + OSYM-845: + issue: COE-470 + issueId: e3ce0e84-3cba-447b-b0b7-2c9380c4a86d + url: https://linear.app/trilogy-ai-coe/issue/COE-470/multi-harness-ide-attachment-qualification + file: docs/tasks/osym-845-acp-debug-integration-tests-and-failure-guidance.md + OSYM-844: + issue: COE-466 + issueId: 5ef5ba64-4a9f-4eb2-be5e-19aa508a63c0 + url: https://linear.app/trilogy-ai-coe/issue/COE-466/default-multi-harness-ide-debug-ux-and-cli-compatibility + file: docs/tasks/osym-844-default-debug-ux-transition-and-cli-compatibility.md diff --git a/docs/tasks/acp-runtime-ide-task-package.yaml b/docs/tasks/acp-runtime-ide-task-package.yaml new file mode 100644 index 000000000..be50bc07f --- /dev/null +++ b/docs/tasks/acp-runtime-ide-task-package.yaml @@ -0,0 +1,34 @@ +planningWave: acp-runtime-and-ide-attachment +tasksDir: docs/tasks +milestones: +- 'M12.99: ACP Harness Runtime Adapter' +- 'M13: ACP Debugging And IDE Attach' +tasks: +- id: OSYM-900 + file: docs/tasks/osym-900-acp-profiles-and-executable-protocol-client.md +- id: OSYM-901 + file: docs/tasks/osym-901-acp-session-ownership-and-durable-recovery.md +- id: OSYM-902 + file: docs/tasks/osym-902-acp-execution-routing-and-worker-integration.md +- id: OSYM-903 + file: docs/tasks/osym-903-acp-client-callbacks-and-session-configuration.md +- id: OSYM-904 + file: docs/tasks/osym-904-acp-operator-requests-and-response-routing.md +- id: OSYM-905 + file: docs/tasks/osym-905-acp-extensions-and-harness-operations.md +- id: OSYM-906 + file: docs/tasks/osym-906-acp-runtime-conformance-and-live-qualification.md +- id: OSYM-840 + file: docs/tasks/osym-840-debug-attachment-core-refactor.md +- id: OSYM-907 + file: docs/tasks/osym-907-debug-control-handoff-and-scheduler-fencing.md +- id: OSYM-841 + file: docs/tasks/osym-841-acp-stdio-server-protocol-adapter.md +- id: OSYM-842 + file: docs/tasks/osym-842-zed-static-agent-configuration-and-setup-ux.md +- id: OSYM-843 + file: docs/tasks/osym-843-tauri-debug-in-zed-launch-action.md +- id: OSYM-845 + file: docs/tasks/osym-845-acp-debug-integration-tests-and-failure-guidance.md +- id: OSYM-844 + file: docs/tasks/osym-844-default-debug-ux-transition-and-cli-compatibility.md diff --git a/docs/tasks/advanced-knowledge-debug-task-package.yaml b/docs/tasks/advanced-knowledge-debug-task-package.yaml index 11da9b6e0..12e7be7e1 100644 --- a/docs/tasks/advanced-knowledge-debug-task-package.yaml +++ b/docs/tasks/advanced-knowledge-debug-task-package.yaml @@ -1,9 +1,9 @@ +# ACP runtime and M13 tasks use acp-runtime-ide-task-package.yaml. planningWave: advanced-knowledge-debugging tasksDir: docs/tasks milestones: - "M10.5: OKF Memory Bundle Foundation" - "M11.5: LLM Wiki Graph View" - - "M13: ACP Debugging And IDE Attach" tasks: - id: OSYM-800 file: docs/tasks/osym-800-okf-bundle-schema-and-legacy-capsule-mapping.md @@ -29,15 +29,3 @@ tasks: file: docs/tasks/osym-825-live-memory-graph-integration-and-privacy-gates.md - id: OSYM-826 file: docs/tasks/osym-826-graph-scale-visual-regression-and-web-desktop-hardening.md - - id: OSYM-840 - file: docs/tasks/osym-840-debug-attachment-core-refactor.md - - id: OSYM-841 - file: docs/tasks/osym-841-acp-stdio-server-protocol-adapter.md - - id: OSYM-842 - file: docs/tasks/osym-842-zed-static-agent-configuration-and-setup-ux.md - - id: OSYM-843 - file: docs/tasks/osym-843-tauri-debug-in-zed-launch-action.md - - id: OSYM-844 - file: docs/tasks/osym-844-default-debug-ux-transition-and-cli-compatibility.md - - id: OSYM-845 - file: docs/tasks/osym-845-acp-debug-integration-tests-and-failure-guidance.md diff --git a/docs/tasks/milestones.md b/docs/tasks/milestones.md index 4fcfa4a74..bba07b459 100644 --- a/docs/tasks/milestones.md +++ b/docs/tasks/milestones.md @@ -198,18 +198,37 @@ Tasks: - Follow-on tasks to be assigned after M10.3 Codex And Subscription Readiness. +## M12.99: ACP Harness Runtime Adapter + +Goal: Run any configured ACP harness within the supported v1 stdio capability matrix through a fully implemented runtime adapter, with host-owned sessions, production execution routing, operator response delivery and custom extensions. + +Tasks: + +- OSYM-900 ACP Profiles And Executable Protocol Client +- OSYM-901 ACP Session Ownership And Durable Recovery +- OSYM-902 ACP Execution Routing And Worker Integration +- OSYM-903 ACP Client Callbacks And Session Configuration +- OSYM-904 ACP Operator Requests And Response Routing +- OSYM-905 ACP Extensions And Harness Operations +- OSYM-906 ACP Runtime Conformance And Live Qualification + ## M13: ACP Debugging And IDE Attach -Goal: Expose OpenSymphony issue debug sessions through Zed ACP while preserving issue workspace, OpenHands conversation, and debug stream ownership in OpenSymphony. +Goal: Attach IDEs to the existing sessions of all supported ACP profiles through the shared runtime owner, with exclusive scheduler/IDE control, preserved native debug paths and multi-harness qualification before the default UX transition. Tasks: -- OSYM-840 Debug Attachment Core Refactor -- OSYM-841 ACP Stdio Server Protocol Adapter -- OSYM-842 Zed Static Agent Configuration And Setup UX -- OSYM-843 Tauri Debug-In-Zed Launch Action -- OSYM-844 Default Debug UX Transition And CLI Compatibility -- OSYM-845 ACP Debug Integration Tests And Failure Guidance +- OSYM-840 Multi-Harness Debug Attachment Core +- OSYM-907 Debug Control Handoff And Scheduler Fencing +- OSYM-841 Multi-Harness ACP IDE Bridge +- OSYM-842 Multi-Harness IDE Setup And Capability Guidance +- OSYM-843 Multi-Harness Desktop Debug-In-Zed Action +- OSYM-845 Multi-Harness IDE Attachment Qualification +- OSYM-844 Default Multi-Harness IDE Debug UX And CLI Compatibility + +Planning source: `docs/tasks/acp-runtime-ide-task-package.yaml`. Runtime implementation +and response wiring are required for M12.99; M13 qualification depends on the +runtime qualification gate. The default debug UX changes after IDE qualification. ## M12.6: Tree-sitter Code Intelligence diff --git a/docs/tasks/osym-840-debug-attachment-core-refactor.md b/docs/tasks/osym-840-debug-attachment-core-refactor.md index 58b4e0fa1..2f887a2aa 100644 --- a/docs/tasks/osym-840-debug-attachment-core-refactor.md +++ b/docs/tasks/osym-840-debug-attachment-core-refactor.md @@ -1,67 +1,68 @@ --- id: OSYM-840 -title: Debug Attachment Core Refactor -milestone: "M13: ACP Debugging And IDE Attach" +title: Multi-Harness Debug Attachment Core +milestone: 'M13: ACP Debugging And IDE Attach' priority: 2 estimate: 8 -blockedBy: [] -blocks: ["OSYM-841", "OSYM-843", "OSYM-844", "OSYM-845"] +blockedBy: +- OSYM-902 +blocks: +- OSYM-907 +- OSYM-843 areas: - - debugging - - openhands-runtime - - cli +- debugging +- acp +- cli parent: null --- ## Summary -Extract reusable debug attachment primitives from the current terminal debug command so CLI and ACP paths share workspace resolution, conversation attach, stream, and turn execution logic. +Refactor debug attachment around the shared runtime owner so every supported ACP profile is a first-class IDE target and existing native debug paths retain their behavior. ## Scope ### In scope -- Introduce a `DebugAttachment` core type or equivalent internal abstraction. -- Extract issue workspace resolution, manifest loading, OpenHands store selection, client construction, stream attach, rehydrate, wait, and run-turn behavior. -- Preserve current terminal debug behavior behind `opensymphony debug --cli`. -- Keep active, archived, and legacy OpenHands conversation store lookup support. +- Resolve the exact issue workspace from issue reference or ACP cwd and verify its repository/run/generation envelope and persisted harness/profile/session binding. +- Use opaque native session IDs and adapter capabilities in the shared attachment core. ACP attaches through the runtime owner control/event seam without launching a second agent. +- Distinguish live attach, capability-gated restoration, transcript inspection and fresh-context reset. Report unsupported restoration clearly; a debug attach never silently starts a replacement conversation. +- Reuse active/archived/legacy OpenHands store resolution and native attach/reconcile. Preserve Codex thread unarchive, codex resume and --app deep-link compatibility. +- Expose attachment status, event subscription and commands for terminal and IDE consumers; keep secrets and native protocol types inside their adapters. ### Out of scope -- ACP JSON-RPC protocol handling. -- Tauri or Zed launch UI. +- IDE ACP wire handling is OSYM-841; exclusive writer transfer is OSYM-907; a new native Codex-to-ACP bridge is not required to preserve native Codex debug. ## Deliverables -- Shared debug attachment core. -- CLI terminal debug path wired through the shared core. -- Regression tests for existing debug workflows. +- Harness-neutral attachment resolution and host-owned session access. +- Regression coverage for native debug paths and multiple ACP profiles. ## Acceptance Criteria -- [ ] Existing terminal debug behavior is preserved through `--cli`. -- [ ] Debug attachment loads `.opensymphony/issue.json` and `.opensymphony/conversation.json` from the exact issue workspace. -- [ ] Active, archived, and legacy OpenHands conversation stores resolve through existing store logic. -- [ ] Shared run-turn logic sends the prompt, runs the conversation, waits for terminal or idle state, and redacts sensitive details. +- [ ] Two different ACP profiles attach through the same core with no vendor-specific debug branch and no new harness process for an existing live owner. +- [ ] Exact workspace identity and runtime envelope validation reject parent/nested/repository/store paths and stale or mismatched metadata. +- [ ] Nonpersistent ACP sessions remain attachable while the host retains them; after loss, the UI distinguishes unavailable context from transcript inspection. +- [ ] OpenHands active/archived/legacy stores and Codex unarchive/resume/--app paths preserve their existing identities and workspace binding. +- [ ] No parallel debug manifest or session database is introduced. ## Test Plan -- Run debug-session unit tests. -- Add tests for valid workspace attach, manifest parsing, active store, archived store, and legacy flat store lookup. -- Run focused CLI debug tests. +- Run workspace binding and debug-session regression tests for ACP, OpenHands and Codex, including profile changes and missing owners. +- Test separate-process attachment uses the owner seam and cannot create a competing runtime. ## Context -- Read `docs/specs/opensymphony-acp-debugging-spec.md`. -- Grounding files include `crates/opensymphony-cli/src/debug_session.rs`, `crates/opensymphony-openhands/src/conversation_store.rs`, and `crates/opensymphony-workspace/src/models.rs`. -- Existing debug command COE work is already delivered; this task refactors it for ACP. +- docs/specs/opensymphony-acp-debugging-spec.md; docs/specs/acp-harness-adapter.md. +- crates/opensymphony-cli/src/debug_session.rs; crates/opensymphony-openhands/src/conversation_store.rs; runtime envelope and control-plane APIs. ## Definition of Ready -- [ ] Hidden assumptions from prior discussion are written down. -- [ ] Required files, docs, and dependencies are explicitly referenced. -- [ ] A coding agent could begin execution without additional planning context. +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. ## Notes -Do not introduce a parallel debug manifest such as `.opensymphony/debug.json`. +M12.99 supplies executable ACP sessions. Native debug compatibility remains capability-explicit; multi-profile ACP support is required. diff --git a/docs/tasks/osym-841-acp-stdio-server-protocol-adapter.md b/docs/tasks/osym-841-acp-stdio-server-protocol-adapter.md index ee9afc1bf..2270b55d0 100644 --- a/docs/tasks/osym-841-acp-stdio-server-protocol-adapter.md +++ b/docs/tasks/osym-841-acp-stdio-server-protocol-adapter.md @@ -1,68 +1,71 @@ --- id: OSYM-841 -title: ACP Stdio Server Protocol Adapter -milestone: "M13: ACP Debugging And IDE Attach" +title: Multi-Harness ACP IDE Bridge +milestone: 'M13: ACP Debugging And IDE Attach' priority: 2 estimate: 13 -blockedBy: ["OSYM-840"] -blocks: ["OSYM-842", "OSYM-845"] +blockedBy: +- OSYM-907 +- OSYM-903 +- OSYM-905 +blocks: +- OSYM-842 +- OSYM-845 areas: - - debugging - - acp - - cli +- debugging +- acp +- cli parent: null --- ## Summary -Add `opensymphony debug --acp-stdio` as a noninteractive ACP JSON-RPC server that attaches Zed or another ACP client to an existing issue conversation. +Implement opensymphony debug --acp-stdio as an ACP server that connects an IDE to the existing host-owned runtime session for any supported ACP profile. ## Scope ### In scope -- Implement ACP stdio server mode under the existing `debug` command family. -- Support initialize, `session/new`, `session/prompt`, and `session/close`. -- Treat `session/new.params.cwd` as the authoritative workspace selection input. -- Enforce one active ACP debug session per spawned process. -- Ensure stdout contains only protocol messages in ACP mode. +- Use the official SDK in server role for initialize, session/new, prompt, cancel and advertised close; keep one attachment per spawned bridge process and stdout protocol-only. +- Negotiate IDE and harness legs independently; initialize uses conservative capabilities before cwd selects a profile, then expose accurate session options and diagnostics. +- Forward ACP-native content/tool updates, config/modes, permitted metadata and registered extensions from the ordered source stream. Normalize native OpenHands events at its boundary; do not reconstruct ACP from lossy scheduler summaries. +- Remap outer session/RPC IDs to bound inner IDs, including bidirectional blocking requests and concurrent updates; route IDE permission/input responses through the same owner validation as operator clients. +- Keep filesystem, terminals, environment and scoped MCP bound to the existing host. Define explicit unsupported/fallback behavior when an IDE lacks a callback or extension already required by the harness. +- Implement writer acquisition, cancellation, completion ordering and close/disconnect through OSYM-907. Optional outer load/resume/list remain unadvertised until implemented. ### Out of scope -- ACP `session/list`, `session/load`, and `session/resume`. -- Zed launch UI. +- A new native Codex ACP bridge, ACP v2, remote IDE hosting and multiple sessions in one bridge process. ## Deliverables -- ACP stdio protocol adapter. -- Strict cwd validation and actionable ACP errors. -- Event mapping from normalized OpenHands runtime events to ACP updates. +- Production ACP server/bridge command and source-event forwarding. +- Bidirectional ID/capability mapping and actionable protocol errors. ## Acceptance Criteria -- [ ] `opensymphony debug --acp-stdio` starts without requiring an issue key. -- [ ] `session/new` rejects parent workspace roots, nested paths, target repo roots, and OpenHands conversation store paths. -- [ ] `session/prompt` sends the message, runs the existing OpenHands conversation, and streams useful updates. -- [ ] `session/close` detaches without deleting workspaces, manifests, memory, or OpenHands conversations. +- [ ] The same bridge attaches to two distinct ACP harness profiles and prompts their existing sessions without vendor-specific bridge branches. +- [ ] IDE callbacks return exactly once to the originating inner request; zero/string IDs, metadata and partial tool updates survive correct mapping. +- [ ] An unsupported IDE feature fails or takes the documented host-operator fallback; attaching cannot expand previously negotiated downstream capabilities. +- [ ] Prompt completion follows the bound terminal result; cancel and close remain responsive under output load and stop attachment-owned work before release. +- [ ] Logs/secrets stay off stdout; invalid cwd/binding, unavailable owner, busy control and unsupported restoration produce actionable errors. ## Test Plan -- Add ACP stdio unit tests with a minimal JSON-RPC harness. -- Add fixture tests for valid cwd and invalid cwd variants. -- Run focused debug-session and OpenHands runtime tests. +- Drive an outer fake ACP IDE and inner fake ACP harness through the actual host owner with interleaved prompts, callbacks, extensions, cancellation and EOF. +- Assert payload fidelity, version/capability negotiation, ID isolation, bounded buffering and native OpenHands mapping. ## Context -- Builds on OSYM-840. -- Read `docs/specs/opensymphony-acp-debugging-spec.md` command surface, ACP method behavior, event mapping, concurrency, and failure sections. -- Keep protocol output off human-readable stdout. +- docs/specs/opensymphony-acp-debugging-spec.md; official ACP v1 initialize, prompt-turn, session-setup and extensibility contracts. +- Shared attachment and handoff from OSYM-840/OSYM-907; ACP source stream, callbacks and extension registry from M12.99. ## Definition of Ready -- [ ] Hidden assumptions from prior discussion are written down. -- [ ] Required files, docs, and dependencies are explicitly referenced. -- [ ] A coding agent could begin execution without additional planning context. +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. ## Notes -The ACP session id is an attachment id, not a raw OpenHands conversation id. +The IDE-facing ACP session ID identifies an attachment; it is distinct from the harness session and control lease. session/close must cancel owned ongoing work. diff --git a/docs/tasks/osym-842-zed-static-agent-configuration-and-setup-ux.md b/docs/tasks/osym-842-zed-static-agent-configuration-and-setup-ux.md index 5347fa142..70aa02eda 100644 --- a/docs/tasks/osym-842-zed-static-agent-configuration-and-setup-ux.md +++ b/docs/tasks/osym-842-zed-static-agent-configuration-and-setup-ux.md @@ -1,67 +1,64 @@ --- id: OSYM-842 -title: Zed Static Agent Configuration And Setup UX -milestone: "M13: ACP Debugging And IDE Attach" +title: Multi-Harness IDE Setup And Capability Guidance +milestone: 'M13: ACP Debugging And IDE Attach' priority: 3 estimate: 3 -blockedBy: ["OSYM-841"] -blocks: ["OSYM-843", "OSYM-844"] +blockedBy: +- OSYM-841 +blocks: +- OSYM-843 areas: - - debugging - - docs - - acp +- debugging +- docs +- acp parent: null --- ## Summary -Document and surface the one-time Zed external-agent configuration needed to start OpenSymphony Debug through ACP. +Provide one static Zed integration and harness-aware setup guidance that works across supported ACP profiles without per-vendor editor configuration. ## Scope ### In scope -- Document the static Zed `agent_servers.opensymphony-debug` configuration. -- Add CLI or app guidance for missing configuration and invalid workspace shape. -- Provide copyable setup text without writing per-issue Zed configuration. -- Include troubleshooting for missing manifests, invalid cwd, and existing OpenHands server store mismatch. +- Document the static opensymphony debug --acp-stdio command and exact issue-workspace selection; verify current Zed agent_servers syntax. +- Explain the attached harness/profile/session, negotiated capabilities, observe/control state, approval destination and host-bound execution facilities. +- Provide recovery guidance for missing editor/owner/manifests, invalid cwd/binding, unsupported extensions/restoration, nonpersistent lost sessions and OpenHands store mismatch. +- Document the ACP client contract for other IDEs while qualifying actual editor support only with evidence. ### Out of scope -- Automatically starting a Zed agent thread from the CLI. -- Writing per-conversation Zed `agent_servers` entries. +- Per-issue agent_servers entries and undocumented editor auto-start APIs. ## Deliverables -- Zed setup documentation. -- Operator-facing setup and recovery messages. -- Tests for guidance text where CLI output is covered. +- Copyable static Zed setup and multi-harness capability/troubleshooting guide. ## Acceptance Criteria -- [ ] A single static Zed external-agent config can start `opensymphony debug --acp-stdio`. -- [ ] Documentation shows how to open an issue workspace and start the OpenSymphony Debug agent. -- [ ] Error guidance tells users to open the exact issue workspace root. -- [ ] No per-issue Zed agent configuration is created. +- [ ] One configuration attaches to issues from two ACP profiles with no provider-specific setup changes. +- [ ] Guidance accurately distinguishes live attach, restored context and transcript inspection, including required host availability. +- [ ] Users can identify who owns control and where to answer a pending request; unsupported IDE extensions have a concrete fallback/error. +- [ ] Examples use the exact issue workspace and expose no secret environment values. ## Test Plan -- Run docs link checks or markdown lint if available. -- Run CLI output tests for setup and failure guidance where implemented. -- Manually verify the documented Zed JSON snippet is syntactically valid. +- Validate the JSON settings example and perform a real Zed setup smoke check. +- Run relevant CLI guidance tests and docs link checks. ## Context -- Builds on OSYM-841. -- Read `docs/specs/opensymphony-acp-debugging-spec.md` Zed integration and desired UX sections. -- The expected static command is `opensymphony debug --acp-stdio`. +- docs/specs/opensymphony-acp-debugging-spec.md: IDE setup; docs/operations.md. +- OSYM-841 capability/errors contract; current official Zed external-agent documentation. ## Definition of Ready -- [ ] Hidden assumptions from prior discussion are written down. -- [ ] Required files, docs, and dependencies are explicitly referenced. -- [ ] A coding agent could begin execution without additional planning context. +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. ## Notes -At MVP scope, the operator starts the external agent from Zed. +Zed is the initial qualified editor. The server protocol remains usable by other compliant clients with supported capabilities. diff --git a/docs/tasks/osym-843-tauri-debug-in-zed-launch-action.md b/docs/tasks/osym-843-tauri-debug-in-zed-launch-action.md index 5e3828044..008cf7eaa 100644 --- a/docs/tasks/osym-843-tauri-debug-in-zed-launch-action.md +++ b/docs/tasks/osym-843-tauri-debug-in-zed-launch-action.md @@ -1,67 +1,65 @@ --- id: OSYM-843 -title: Tauri Debug-In-Zed Launch Action -milestone: "M13: ACP Debugging And IDE Attach" +title: Multi-Harness Desktop Debug-In-Zed Action +milestone: 'M13: ACP Debugging And IDE Attach' priority: 3 estimate: 5 -blockedBy: ["OSYM-840", "OSYM-842"] -blocks: ["OSYM-844", "OSYM-845"] +blockedBy: +- OSYM-840 +- OSYM-842 +blocks: +- OSYM-845 areas: - - debugging - - desktop - - tauri +- debugging +- desktop +- tauri parent: null --- ## Summary -Wire the desktop app debug action to resolve an issue key to the exact issue workspace path and open Zed on that workspace. +Launch the selected issue workspace in Zed from the desktop app with accurate harness and attachment readiness, using the shared debug resolver. ## Scope ### In scope -- Add a Tauri or gateway command that resolves an issue key to its exact OpenSymphony issue workspace path. -- Launch `zed -n ` where available. -- Show concise instruction text telling the operator to start the OpenSymphony Debug external agent in Zed. -- Handle missing Zed, missing workspace, and missing manifests with recoverable UI feedback. +- Use existing gateway/control APIs to resolve verified workspace, harness/profile, restoration availability and control state. +- Launch zed -n with an argv path and present the instruction to start the configured OpenSymphony Debug agent. +- Handle missing Zed, invalid workspace, lost owner/context, busy control and unsupported attachment capability with actionable UI states. +- Keep native Codex resume/--app routes available where advertised; UI consumes capabilities instead of a hard-coded OpenHands eligibility check. ### Out of scope -- Auto-starting Zed agent threads. -- Creating additional workspace debug manifests. +- Auto-starting editor threads without a documented API, embedding runtime clients in Tauri, and new debug manifests. ## Deliverables -- Desktop debug action plumbing. -- Workspace path resolution API or command. -- UI states for launch success, missing editor, and invalid workspace. +- Capability-driven debug action, safe workspace launch plumbing and recovery UI. ## Acceptance Criteria -- [ ] Tauri can open Zed on the exact issue workspace root for a selected issue. -- [ ] The app does not open target repo roots or OpenHands conversation store directories for ACP debug. -- [ ] The UI leaves rich orchestration and visualization in OpenSymphony while Zed owns code inspection and manual edits. -- [ ] Missing editor or invalid workspace states include actionable recovery text. +- [ ] Issues from two ACP profiles can launch the same IDE flow, displaying the correct bound harness/profile and control readiness. +- [ ] The launched path is the exact verified issue workspace, never a repository root, parent directory or runtime store. +- [ ] Native debug alternatives remain explicit and available; unavailable features are not presented as working actions. +- [ ] Failure states provide recovery steps without starting a second harness or changing scheduler state directly. ## Test Plan -- Add unit tests for workspace resolution and command payloads. -- Add desktop integration tests or manual verification for the launch command where CI permits. -- Run frontend tests for debug action UI states. +- Test shared resolver payloads, path quoting/argv handling and UI capability states. +- Perform desktop-to-Zed launch smoke tests for two profiles; use shared fixtures for frontend tests. ## Context -- Builds on OSYM-840 and OSYM-842. -- Read `docs/specs/opensymphony-acp-debugging-spec.md` Tauri integration and workspace selection sections. -- Existing desktop action surfaces live near the shared client and Tauri shell code. +- docs/specs/opensymphony-acp-debugging-spec.md: desktop and command surface. +- Shared API client/debug action surfaces and Tauri shell; OSYM-840 resolver and OSYM-842 guidance. ## Definition of Ready -- [ ] Hidden assumptions from prior discussion are written down. -- [ ] Required files, docs, and dependencies are explicitly referenced. -- [ ] A coding agent could begin execution without additional planning context. +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. ## Notes -OpenSymphony owns workspace resolution; Zed owns code and agent thread UI. +The app opens the workspace; the user starts the static external agent. Acquiring runtime control belongs to the orchestrator handoff. diff --git a/docs/tasks/osym-844-default-debug-ux-transition-and-cli-compatibility.md b/docs/tasks/osym-844-default-debug-ux-transition-and-cli-compatibility.md index e9052f514..36b924123 100644 --- a/docs/tasks/osym-844-default-debug-ux-transition-and-cli-compatibility.md +++ b/docs/tasks/osym-844-default-debug-ux-transition-and-cli-compatibility.md @@ -1,67 +1,63 @@ --- id: OSYM-844 -title: Default Debug UX Transition And CLI Compatibility -milestone: "M13: ACP Debugging And IDE Attach" +title: Default Multi-Harness IDE Debug UX And CLI Compatibility +milestone: 'M13: ACP Debugging And IDE Attach' priority: 3 estimate: 5 -blockedBy: ["OSYM-840", "OSYM-842", "OSYM-843"] -blocks: ["OSYM-845"] +blockedBy: +- OSYM-845 +blocks: [] areas: - - debugging - - cli - - desktop +- debugging +- cli +- desktop parent: null --- ## Summary -Transition `opensymphony debug ` toward the IDE-oriented debug workflow while preserving terminal debug compatibility through `--cli`. +Make the qualified IDE flow the default debug experience while preserving explicit terminal and native harness debugging options. ## Scope ### In scope -- Make the default debug command resolve or prepare the preferred IDE debug experience after ACP and Tauri launch paths are stable. -- Preserve terminal interactive debugging through `opensymphony debug --cli`. -- Print concise fallback instructions when Zed or the desktop integration is unavailable. -- Update CLI help, operations docs, and debugging docs. +- Route opensymphony debug to the capability-appropriate IDE flow after OSYM-845 qualification. +- Preserve --cli terminal behavior, native Codex resume/unarchive and --app deep links, and explicit noninteractive --acp-stdio semantics. +- Provide usable fallback guidance for missing Zed, unavailable owner, lost nonpersistent context or unsupported native IDE capability. +- Update help, operations and debugging docs with command precedence and profile/capability behavior. ### Out of scope -- Removing terminal debug support. -- Hosted browser IDE integration. +- Removing terminal debug or native harness alternatives; hosted browser IDE integration. ## Deliverables -- Updated debug command behavior and flags. -- CLI help and operations docs. -- Regression tests for `--cli`, `--acp-stdio`, and default debug paths. +- Qualified default command routing, compatibility flags and documented fallback behavior. ## Acceptance Criteria -- [ ] `opensymphony debug --cli` preserves the terminal workflow. -- [ ] `opensymphony debug --acp-stdio` remains noninteractive and does not require an issue key. -- [ ] `opensymphony debug ` produces the IDE-oriented path or clear fallback guidance. -- [ ] Documentation explains the mode split without encouraging unsupported OpenHands protocols. +- [ ] The default debug flow works for both qualified ACP profiles and uses accurate capabilities for native routes. +- [ ] --cli, --app and --acp-stdio retain their documented semantics; incompatible flag combinations fail clearly. +- [ ] No fallback silently resets agent context, creates a second live runtime, or bypasses writer ownership. +- [ ] OSYM-845 evidence is complete before the default changes; CLI help and operations examples match implemented behavior. ## Test Plan -- Run CLI debug regression tests. -- Run docs checks for debug command references. -- Manually verify help output for default, `--cli`, and `--acp-stdio` paths. +- Run command dispatch/help and native debug regression tests. +- Repeat the default entrypoint smoke path using the already qualified profiles; verify missing-editor fallback. ## Context -- Builds on OSYM-840, OSYM-842, and OSYM-843. -- Read `docs/specs/opensymphony-acp-debugging-spec.md` command surface and default debug UX transition sections. -- Respect AGENTS.md: OpenSymphony targets SDK agent-server REST plus WebSocket, not web-app Socket.IO. +- docs/specs/opensymphony-acp-debugging-spec.md: commands and rollout. +- OSYM-845 qualification gate; crates/opensymphony-cli/src/debug_session.rs and CLI argument declarations. ## Definition of Ready -- [ ] Hidden assumptions from prior discussion are written down. -- [ ] Required files, docs, and dependencies are explicitly referenced. -- [ ] A coding agent could begin execution without additional planning context. +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. ## Notes -This task should happen after the ACP and Zed launch paths are demonstrably stable. +This is the last M13 task: validation precedes the default UX switch. diff --git a/docs/tasks/osym-845-acp-debug-integration-tests-and-failure-guidance.md b/docs/tasks/osym-845-acp-debug-integration-tests-and-failure-guidance.md index bf05e7acd..dee34e710 100644 --- a/docs/tasks/osym-845-acp-debug-integration-tests-and-failure-guidance.md +++ b/docs/tasks/osym-845-acp-debug-integration-tests-and-failure-guidance.md @@ -1,67 +1,70 @@ --- id: OSYM-845 -title: ACP Debug Integration Tests And Failure Guidance -milestone: "M13: ACP Debugging And IDE Attach" +title: Multi-Harness IDE Attachment Qualification +milestone: 'M13: ACP Debugging And IDE Attach' priority: 2 estimate: 8 -blockedBy: ["OSYM-840", "OSYM-841", "OSYM-843", "OSYM-844"] -blocks: [] +blockedBy: +- OSYM-841 +- OSYM-843 +- OSYM-906 +- OSYM-907 +blocks: +- OSYM-844 areas: - - debugging - - acp - - testing +- debugging +- acp +- testing parent: null --- ## Summary -Add end-to-end ACP debug coverage and polish failure guidance for invalid workspaces, missing manifests, missing conversations, active turns, and store mismatches. +Qualify scheduler-to-IDE attachment across multiple ACP harnesses before the default debug command switches to the IDE flow. ## Scope ### In scope -- Add a minimal ACP stdio client harness around the fake OpenHands server. -- Test initialize, `session/new`, `session/prompt`, `session/close`, and stream detach behavior. -- Cover invalid cwd variants, missing conversation manifest, invalid conversation id, missing OpenHands conversation, already-running turn, and existing server with different store. -- Verify ACP close leaves durable issue workspaces, manifests, memory, and OpenHands conversations intact. +- Exercise real opensymphony run -> retained runtime owner -> separate ACP bridge -> IDE prompt -> operator callback -> cancellation/release -> scheduler continuation. +- Use an adversarial fake IDE/harness pair plus at least two independently implemented live ACP harnesses with pinned versions, and a real Zed smoke test for both profiles. +- Cover vendor extension requests, metadata fidelity, unsupported IDE fallback, optional persistence differences, archived native debug and exact workspace binding. +- Inject scheduler/IDE races, pending approvals, disconnects, stale lease/replies, host loss, output floods and cleanup attempts. +- Publish compatibility/failure guidance and reproducible evidence separately for fake protocol tests and actual harness/editor runs. ### Out of scope -- Zed UI automation. -- Multi-session ACP multiplexing. +- Requiring Zed installation for hermetic CI; broad GUI automation or editor multiplexing. ## Deliverables -- ACP debug integration test harness. -- Failure-mode tests and operator guidance assertions. -- Documentation updates for troubleshooting. +- End-to-end attachment/race regression suite and live editor evidence. +- Verified capability matrix and troubleshooting for runtime/IDE boundary failures. ## Acceptance Criteria -- [ ] A fake-server integration test attaches to a fixture issue workspace and runs a prompt through ACP. -- [ ] Invalid cwd errors identify the expected exact issue workspace shape. -- [ ] Missing manifest and invalid conversation id errors name the relevant file or field. -- [ ] `session/close` is verified as detach-only behavior. +- [ ] Two real ACP profiles attach to the same session used by the scheduler and exchange debug prompts through Zed; no duplicate agent or fresh-context substitution occurs. +- [ ] A vendor blocking request and its validated IDE/operator response traverse both protocol legs, or a documented capability fallback is explicitly exercised. +- [ ] Ownership tests prove no concurrent scheduler/IDE prompts, no duplicate/stale replies, and no scheduler release before debug work settles. +- [ ] Close cancels owned work before releasing resources while preserving durable workspace/session data; disconnect uncertainty remains fenced. +- [ ] OpenHands store compatibility and native Codex unarchive/resume/--app regressions pass; the qualification report gates OSYM-844. ## Test Plan -- Run the ACP debug integration test harness. -- Run debug-session unit and CLI regression tests. -- Run fake OpenHands server contract tests touched by debug attachment. +- Run fake IDE/ACP/runtime integration, scheduler race and native debug regression tests. +- Run documented live Zed attachment scenarios for both pinned profiles and record exact versions, capabilities, outcomes and limitations. ## Context -- Builds on OSYM-840, OSYM-841, OSYM-843, and OSYM-844. -- Read `docs/specs/opensymphony-acp-debugging-spec.md` failure behavior, test plan, and acceptance criteria. -- Keep legacy flat, active, and archived OpenHands stores supported. +- docs/specs/opensymphony-acp-debugging-spec.md: acceptance and failure matrix. +- OSYM-906 runtime qualification evidence, OSYM-907 handoff tests and OSYM-841 bridge. ## Definition of Ready -- [ ] Hidden assumptions from prior discussion are written down. -- [ ] Required files, docs, and dependencies are explicitly referenced. -- [ ] A coding agent could begin execution without additional planning context. +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. ## Notes -The test harness should not depend on Zed being installed. +The default UX transition depends on this task. A fake OpenHands-only test cannot satisfy multi-harness IDE qualification. diff --git a/docs/tasks/osym-900-acp-profiles-and-executable-protocol-client.md b/docs/tasks/osym-900-acp-profiles-and-executable-protocol-client.md new file mode 100644 index 000000000..4fa922ca3 --- /dev/null +++ b/docs/tasks/osym-900-acp-profiles-and-executable-protocol-client.md @@ -0,0 +1,66 @@ +--- +id: OSYM-900 +title: ACP Profiles And Executable Protocol Client +milestone: 'M12.99: ACP Harness Runtime Adapter' +priority: 2 +estimate: 8 +blockedBy: [] +blocks: +- OSYM-901 +- OSYM-903 +areas: +- acp +- configuration +- runtime +parent: null +--- + +## Summary + +Implement a working ACP v1 stdio client and named launch profiles using the official Rust SDK. The deliverable runs a real child process through a complete prompt and cancellation lifecycle. + +## Scope + +### In scope + +- Add the internal opensymphony-acp source module through src/lib.rs; pin the validated agent-client-protocol SDK/schema without creating another Cargo package or copying the Codex RPC client. +- Add typed central/workflow ACP profile configuration and routing.harness_profile. Validate argv, environment references, supported versions/transports, authentication method, required capabilities, and enabled extension names. +- Launch with verified issue cwd, credential exclusions and bounded stderr/stdout; register bidirectional handlers before initialize, negotiate v1, authenticate, create a session, submit prompts, stream ordered updates, and cancel. +- Keep the dispatch loop responsive during callbacks. Preserve opaque IDs, unknown payloads and stop reasons in bounded redacted evidence. Implement explicit errors, submission uncertainty, deadlines and supervised teardown. + +### Out of scope + +- ACP v2 or network transport implementation; production execution routing is OSYM-902; complete callbacks and operator policy are OSYM-903 and OSYM-904. + +## Deliverables + +- Executable SDK-backed client and validated profile configuration. +- Adversarial fake ACP subprocess reusable by later runtime and IDE tests. + +## Acceptance Criteria + +- [ ] A fake child completes initialize, session/new, session/prompt, interleaved updates and terminal response using the same launch path intended for runtime use. +- [ ] A cancelled prompt completes only after its terminal response or an explicit timeout/failure path; sending session/cancel never counts as acknowledgement. +- [ ] Unknown requests receive method-not-found, notifications receive no reply, IDs including 0 correlate correctly, and unsupported versions fail clearly. +- [ ] Invalid profile/env/cwd combinations fail before launch; secrets remain absent from config rendering, arguments and captured diagnostics. +- [ ] Only implemented client capabilities are advertised; SDK integration handles callback concurrency and ordered completion under Tokio without blocking the executor. + +## Test Plan + +- Run focused config/parser and ACP subprocess tests for malformed/oversized frames, EOF/crash, unknown stop reasons, output floods and an unresponsive child. +- Run cargo fmt --check and the relevant cargo test-system-duckdb and cargo clippy-system-duckdb targets. + +## Context + +- docs/specs/acp-harness-adapter.md: repository fit, configuration, protocol and cancellation. +- src/lib.rs; Cargo.toml; crates/opensymphony-workflow/src/; central config resolver; crates/opensymphony-codex/src/ for existing supervision patterns. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +This is an implementation task, not an SDK spike. ACP wire v1 and the SDK package version are separate identifiers. diff --git a/docs/tasks/osym-901-acp-session-ownership-and-durable-recovery.md b/docs/tasks/osym-901-acp-session-ownership-and-durable-recovery.md new file mode 100644 index 000000000..35f1eeb12 --- /dev/null +++ b/docs/tasks/osym-901-acp-session-ownership-and-durable-recovery.md @@ -0,0 +1,67 @@ +--- +id: OSYM-901 +title: ACP Session Ownership And Durable Recovery +milestone: 'M12.99: ACP Harness Runtime Adapter' +priority: 2 +estimate: 8 +blockedBy: +- OSYM-900 +blocks: +- OSYM-902 +areas: +- acp +- runtime +- workspace +parent: null +--- + +## Summary + +Own each ACP session in the OpenSymphony runtime host across worker attempts and IDE attachments, with durable identity and truthful recovery after process or host failure. + +## Scope + +### In scope + +- Implement one supervised per-issue process/connection owner with command and ordered event channels; a worker borrows the session instead of defining its lifetime. +- Retain idle sessions according to explicit bounded host retention policy, including sessions without persistence. Existing attachment/control leases pin the session; resource limits refuse new launches or visibly end eligible idle sessions. +- Persist additive harness kind/profile/fingerprint, opaque session, workspace/repository/run/generation, negotiated capabilities and submission/outcome markers in existing manifests/runtime envelopes. +- Implement optional load/resume with replay separation, profile/grant compatibility checks, reset policy, uncertain-submission fencing, owner loss and cleanup. Publish attach-ready identity/state for M13 without implementing IDE protocol handling. +- Expose host commands/events through the existing control-plane boundary so a separate debug process can reach the owner. Define single-owner generation fencing and refuse a second process for a live session. + +### Out of scope + +- IDE writer transfer and scheduler holds are OSYM-907. Cross-issue process pools and generic plugin infrastructure. + +## Deliverables + +- Host-owned session actor, durable session identity and control-plane command/event seam. +- Recovery and retention policy with diagnostics distinguishing live attach, restored context, transcript inspection and fresh reset. + +## Acceptance Criteria + +- [ ] A finished worker attempt leaves an eligible live session available to the next attempt and IDE attachment; releasing one subscriber does not kill shared execution. +- [ ] Nonpersistent sessions attach while alive; after owner death the system never describes a fresh session or recorded transcript as restored agent context. +- [ ] Possible prompt delivery without a terminal result is fenced as uncertain and never automatically replayed, including across restart. +- [ ] Profile, repository, workspace, generation and credential-scope mismatches prevent unsafe reuse; legacy native manifests remain readable. +- [ ] Cleanup waits for execution quiescence and releases owned terminal/process resources; EOF alone is not proof that a harness delegating remote work stopped. + +## Test Plan + +- Test concurrent sessions and repeated worker attempts, retention expiry with/without attachments, host crash before/after prompt submission, load replay, resume without replay and unsupported persistence. +- Test cross-process duplicate owner rejection and stale generation commands; run workspace/session persistence regressions. + +## Context + +- docs/specs/acp-harness-adapter.md: protocol/session lifecycle and persistence; docs/specs/opensymphony-acp-debugging-spec.md: shared owner and binding. +- crates/opensymphony-workspace/src/models.rs; crates/opensymphony-openhands/src/session.rs; crates/opensymphony-control/src/; existing runtime envelopes and cleanup fences. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +Use existing persistence and control surfaces. No .opensymphony/debug.json, sidecar session database, or independent scheduling owner. diff --git a/docs/tasks/osym-902-acp-execution-routing-and-worker-integration.md b/docs/tasks/osym-902-acp-execution-routing-and-worker-integration.md new file mode 100644 index 000000000..d08bc0ad5 --- /dev/null +++ b/docs/tasks/osym-902-acp-execution-routing-and-worker-integration.md @@ -0,0 +1,70 @@ +--- +id: OSYM-902 +title: ACP Execution Routing And Worker Integration +milestone: 'M12.99: ACP Harness Runtime Adapter' +priority: 2 +estimate: 8 +blockedBy: +- OSYM-901 +blocks: +- OSYM-904 +- OSYM-906 +- OSYM-840 +areas: +- acp +- orchestrator +- cli +parent: null +--- + +## Summary + +Route real opensymphony run work to ACP profiles and wire the complete worker lifecycle. Capability discovery alone cannot satisfy this task. + +## Scope + +### In scope + +- Add harness kind acp and profile identity to route resolution and persisted HarnessRouteDecision; propagate central/workflow precedence and model selection without reusing OpenHands credential assumptions. +- Wire WorkerBackend start/recover/poll/abort/interrupt and CLI run startup into the session owner. Replace Codex-versus-other branches in dispatch, session switching and recovery with explicit adapter identity matching. +- Reuse verified task/repository binding, cwd, hooks, instruction provenance, scoped memory grants, review context and runtime envelope; ACP-only dispatch must not start an OpenHands server. +- Normalize live runtime updates/outcomes/usage through worker messages while retaining a separate redacted ACP source stream for IDE forwarding; apply partial tool updates and replay accounting correctly. +- Implement scheduler-visible waiting/failed/cancelled/uncertain outcomes and cleanup gates. Public capability discovery reports adapter, profile and negotiated run support in Rust and TypeScript. + +### Out of scope + +- New scheduling policy, provider-specific enum variants and IDE presentation. Operator response completion is OSYM-904. + +## Deliverables + +- Concrete ACP backend routing in production run paths. +- Cross-language capability/config projections and native harness regression coverage. + +## Acceptance Criteria + +- [ ] An isolated real opensymphony run dispatches a tracked fixture issue to a configured fake ACP executable, receives updates and a terminal outcome, and never launches OpenHands. +- [ ] Two ACP profiles route independently; changing profile cannot reuse incompatible context; recovery honors persisted profile identity instead of the current default. +- [ ] Interrupt, abort, continuation, failure/retry and terminal cleanup exercise the ACP owner and preserve scheduler authority and workspace invariants. +- [ ] Adapter availability, profile preflight readiness and effective run capabilities are distinguishable; absent usage stays absent and replay is not charged twice. +- [ ] OpenHands and Codex routes retain their existing launch, interrupt and recovery behavior. + +## Test Plan + +- Drive the actual CLI/backend with fake tracker and ACP peers; assert hooks, exact cwd, memory scope, profile switches, recovery and no OpenHands startup. +- Run adapter-boundary, scheduler, gateway capabilities, Rust/TypeScript round-trip and native routing tests. + +## Context + +- crates/opensymphony-orchestrator/src/scheduler.rs: WorkerBackend, HarnessRouteDecision, WorkerUpdate. +- `crates/opensymphony-cli/src/orchestrator_run/backends.rs` and `crates/opensymphony-cli/src/orchestrator_run/mod.rs`; crates/opensymphony-domain/src/harness.rs; crates/opensymphony-gateway-schema/src/capability.rs; packages/gateway-schema/src/capability.ts. +- docs/specs/acp-harness-adapter.md; docs/harness-adapter-compatibility.md; docs/configuration.md; docs/architecture.md. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +Keep protocol types inside opensymphony-acp. HarnessAdapter currently discovers capabilities; actual execution integration is mandatory here. diff --git a/docs/tasks/osym-903-acp-client-callbacks-and-session-configuration.md b/docs/tasks/osym-903-acp-client-callbacks-and-session-configuration.md new file mode 100644 index 000000000..d245127f8 --- /dev/null +++ b/docs/tasks/osym-903-acp-client-callbacks-and-session-configuration.md @@ -0,0 +1,69 @@ +--- +id: OSYM-903 +title: ACP Client Callbacks And Session Configuration +milestone: 'M12.99: ACP Harness Runtime Adapter' +priority: 2 +estimate: 8 +blockedBy: +- OSYM-900 +blocks: +- OSYM-904 +- OSYM-906 +- OSYM-841 +areas: +- acp +- runtime +- workspace +parent: null +--- + +## Summary + +Implement the ACP client facilities that allow different harnesses to execute within the bound issue workspace and use scoped tools consistently. + +## Scope + +### In scope + +- Implement fs/read_text_file and fs/write_text_file with specified text/line semantics, containment, symlink checks and limits. +- Implement terminal create/output/wait_for_exit/kill/release with session-bound IDs, cwd/env policy, bounded output, cancellation and teardown. +- Attach existing scoped MCP memory/tool servers using negotiated supported transports; apply advertised model/config options and legacy modes before prompting and consume updates. +- Gate advertised filesystem/terminal/auth/elicitation capabilities on implemented handlers and policy. Preserve callback ordering while long waits run asynchronously. +- Keep execution facilities owned by the host when an IDE later attaches; IDE-provided environment, filesystems, terminals or MCP entries cannot silently replace the running session contract. + +### Out of scope + +- Operator approval/input responses are OSYM-904; remote transports and a new sandbox. + +## Deliverables + +- Production filesystem/terminal callbacks and configuration/MCP setup. +- Callback capability matrix and resource lifecycle tests. + +## Acceptance Criteria + +- [ ] A harness completes a workspace read/write and terminal command with correct output, exit state and release handling; another session cannot reuse its handles. +- [ ] Path traversal, symlink escape, unauthorized cwd and oversized requests are rejected, including nonexistent write targets. +- [ ] Unsupported explicit model/config choices or required MCP transport fail clearly rather than silently selecting defaults. +- [ ] Cancellation and host shutdown resolve or expire outstanding callbacks and reap owned children without blocking RPC dispatch. +- [ ] Late attachment leaves the established execution environment and scoped memory grants intact. + +## Test Plan + +- Run focused ACP callback tests for line ranges, output truncation, terminal exit races, release-before-exit, cancellation and cross-session access. +- Exercise negotiated capability absence, model/config changes and scoped MCP attachment through the executable fake peer. + +## Context + +- docs/specs/acp-harness-adapter.md: client callbacks and effective capabilities. +- Existing workspace containment, scoped memory/environment and subprocess utilities; official ACP v1 filesystem, terminals and session-config-options contracts. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +The local agent remains a trusted host process. Callback containment must not be described as a sandbox. diff --git a/docs/tasks/osym-904-acp-operator-requests-and-response-routing.md b/docs/tasks/osym-904-acp-operator-requests-and-response-routing.md new file mode 100644 index 000000000..a00654497 --- /dev/null +++ b/docs/tasks/osym-904-acp-operator-requests-and-response-routing.md @@ -0,0 +1,72 @@ +--- +id: OSYM-904 +title: ACP Operator Requests And Response Routing +milestone: 'M12.99: ACP Harness Runtime Adapter' +priority: 2 +estimate: 13 +blockedBy: +- OSYM-902 +- OSYM-903 +blocks: +- OSYM-905 +- OSYM-906 +- OSYM-907 +areas: +- acp +- orchestrator +- gateway +- ui +parent: null +--- + +## Summary + +Complete the round trip from a harness permission or information request to a real operator response and back to the blocked RPC. + +## Scope + +### In scope + +- Add worker request/response channels and orchestrator-owned pending interactions bound to run, session, connection generation and request ID; preserve option IDs/kinds and validate structured questions. +- Populate run approvals/input requests in snapshots and gateway APIs. Wire existing ApprovalDecision/ActionReceipt plus typed input responses through authorization, command dispatch and the session owner. +- Expose actionable web/desktop and terminal operator controls through existing clients; support approve/deny, choice/form submit, decline and cancel with response deadlines and receipts. +- Implement explicit operator, deny and authorized allow_once policies; unattended operator mode without a response path fails visibly. Elicitation capabilities remain disabled until their actual flow is implemented. +- Handle timeout, disconnect, turn cancellation, duplicate/stale replies and ownership changes without hanging a harness. Make waiting-for-input visible to stall detection and concurrency accounting. + +### Out of scope + +- Arbitrary RPC passthrough and vendor method registration, which belongs to OSYM-905. + +## Deliverables + +- Working pending-interaction projection, operator commands and worker response delivery. +- Reviewable operator controls and interaction diagnostics across configured clients. + +## Acceptance Criteria + +- [ ] A real opensymphony run receives an offered permission, exposes it via the gateway/UI, accepts a valid operator decision, and emits exactly one matching ACP response with the original option and RPC IDs. +- [ ] The same production route completes a structured question; permissions, questions and plan approval have distinct typed semantics. +- [ ] Wrong run/session/generation, invalid option, expired and duplicate responses are rejected; no old approval is replayed after restart. +- [ ] Cancellation answers pending requests using the appropriate cancellation result, and timeout cannot leave the worker or RPC permanently blocked. +- [ ] Other issues continue running while an issue awaits input. Secrets are not elicited through forms or leaked into public snapshots; URL elicitation requires a consented out-of-band flow if enabled. + +## Test Plan + +- Exercise worker -> orchestrator -> gateway/client -> command -> worker -> ACP in one integration test, including terminal and web/desktop response clients. +- Test deny/allow_once/operator policies, zero request IDs, stale responses, duplicate submissions, deadlines, disconnect and cancellation races. + +## Context + +- crates/opensymphony-gateway/src/lib.rs: get_run_approvals currently returns no runtime requests. +- ApprovalRequest, ApprovalDecision and ActionReceipt contracts; scheduler worker messages; control-plane actions; packages/api-client and shared operator UI. +- docs/specs/acp-harness-adapter.md: client callbacks and operator responses. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +A DTO, empty endpoint, optimistic receipt, or UI-only approval is not completion. Evidence must show the harness receives the response. diff --git a/docs/tasks/osym-905-acp-extensions-and-harness-operations.md b/docs/tasks/osym-905-acp-extensions-and-harness-operations.md new file mode 100644 index 000000000..7995edf54 --- /dev/null +++ b/docs/tasks/osym-905-acp-extensions-and-harness-operations.md @@ -0,0 +1,68 @@ +--- +id: OSYM-905 +title: ACP Extensions And Harness Operations +milestone: 'M12.99: ACP Harness Runtime Adapter' +priority: 2 +estimate: 8 +blockedBy: +- OSYM-904 +blocks: +- OSYM-906 +- OSYM-841 +areas: +- acp +- gateway +- runtime +parent: null +--- + +## Summary + +Support configured custom ACP requests, notifications and metadata in both directions, including operator-invoked harness operations and a verified vendor callback. + +## Scope + +### In scope + +- Use SDK typed/raw hooks for a small registered extension table: namespace/version, direction, capability predicate, method, parameter/result validation, deadline and effect policy. +- Preserve permitted _meta and unknown redacted evidence; implement explicit handling for inbound requests and notifications. Use descriptors for simple operations and Rust handlers for stateful/domain behavior. +- Wire a registered HarnessOperation operator action through authorization, orchestrator/session binding, runtime invocation and result/error receipt. Never accept an arbitrary wire method/session from clients. +- Implement version-tested Cursor compatibility for its documented non-underscore method names using captured envelopes; route blocking questions/plans into OSYM-904 and known notifications into activity/artifact projections. +- Expose enabled operation schemas and capability predicates for downstream IDE negotiation. Peer support never implies permission, and timeouts on mutations remain outcome-unknown. + +### Out of scope + +- Dynamic plugin loading, a scripting runtime, invented Devin operations or implementation of an Exo ACP facade. + +## Deliverables + +- Bidirectional extension registry, at least one real vendor handler and a complete outbound operator operation path. +- Capability/schema fixtures and redacted wire evidence for supported versions. + +## Acceptance Criteria + +- [ ] A blocking vendor request reaches an operator, validates the reply and responds exactly once; notifications emit no RPC response and request ID 0 remains valid. +- [ ] An enabled outbound operation is discoverable and invoked through the production operator action path with the correct bound session and structured result. +- [ ] Unknown requests fail with method-not-found; unknown notifications remain bounded evidence; malformed/disabled/version-mismatched methods cannot bypass policy or hang the harness. +- [ ] Namespaced metadata survives supported forwarding without becoming executable input; lifecycle mutations still pass through orchestrator commands. +- [ ] At least one vendor interaction is validated with a pinned real harness, while a deterministic fake extension tests the outbound path when no live safe operation exists. + +## Test Plan + +- Test inbound/outbound requests, notifications, metadata, schema validation, ID correlation, deadlines, cancellation, rejected effects and no unsafe retry. +- Capture redacted Cursor frames and verify actual request/notification envelopes before enabling compatibility registrations. + +## Context + +- docs/specs/acp-harness-adapter.md: custom extensions and concrete vendor handling. +- https://agentclientprotocol.com/protocol/v1/extensibility; https://cursor.com/docs/cli/acp; existing operator action authorization/receipts. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +Use the generic acp harness kind for every vendor profile. Supporting an extension means implementing its response/lifecycle semantics, not merely logging its name. diff --git a/docs/tasks/osym-906-acp-runtime-conformance-and-live-qualification.md b/docs/tasks/osym-906-acp-runtime-conformance-and-live-qualification.md new file mode 100644 index 000000000..8c9c77861 --- /dev/null +++ b/docs/tasks/osym-906-acp-runtime-conformance-and-live-qualification.md @@ -0,0 +1,70 @@ +--- +id: OSYM-906 +title: ACP Runtime Conformance And Live Qualification +milestone: 'M12.99: ACP Harness Runtime Adapter' +priority: 2 +estimate: 8 +blockedBy: +- OSYM-902 +- OSYM-903 +- OSYM-904 +- OSYM-905 +blocks: +- OSYM-845 +areas: +- acp +- testing +- docs +parent: null +--- + +## Summary + +Qualify the complete ACP execution path with deterministic failure tests and two independent live harnesses, and publish the supported interoperability contract. + +## Scope + +### In scope + +- Run actual opensymphony run scenarios covering configured profile routing, workspace edits, callbacks, operator responses, cancellation, continuation, restart and cleanup. +- Qualify at least two structurally independent ACP harness implementations with pinned versions, including one supported vendor extension. Use Cursor plus an available DeepSeek Harness or Devin CLI candidate after checking current contracts. +- Exercise optional persistence absent, load with replay, resume without replay, model/config variation and credential failures; record explicit limitations rather than promising universal optional features. +- Complete capabilities endpoint/adapter-boundary and Rust/TypeScript parity checks; audit no ACP route falls through to OpenHands startup. +- Update architecture, configuration, operations, workspace/lifecycle, harness compatibility, testing and sources docs with executable examples, exact versions and redacted evidence. + +### Out of scope + +- IDE attachment qualification is OSYM-845; ACP v2, generic remote transports, new native vendor adapters and EBO ingestion. + +## Deliverables + +- Reproducible fake-peer conformance suite and two live harness evidence reports. +- Operator setup/troubleshooting and explicit adapter/profile/negotiated capability matrix. + +## Acceptance Criteria + +- [ ] Two independently implemented live ACP harnesses complete tracked issue runs through the real routing path; fake-peer tests alone cannot satisfy this gate. +- [ ] The live evidence demonstrates workspace modification, a returned operator decision, cancellation and documented restoration/reset behavior, plus a vendor extension round trip. +- [ ] Failure tests prove no ambiguous prompt resend, cross-session reply, duplicate replay accounting, unsafe cleanup or false cancellation acknowledgement. +- [ ] Examples configure a new compliant profile without adding a vendor-specific scheduler or debug branch. +- [ ] Documentation states version/transport/authentication boundaries and identifies any unavailable live prerequisite as an unmet acceptance criterion. + +## Test Plan + +- Run relevant system-DuckDB tests, cargo fmt --check, clippy and touched frontend/schema checks; run bundled-mode checks required for the new SDK dependency. +- Run pinned live profile scenarios in isolated issue workspaces with scoped credentials; record commands, versions, outcomes and source-frame references. + +## Context + +- All M12.99 implementation tasks; docs/specs/acp-harness-adapter.md; docs/testing-and-operations.md; docs/sources.md. +- Reuse fake ACP peer from OSYM-900 and actual run integration from OSYM-902. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +Publish availability truthfully. A configured profile or successful SDK seam test is not live harness qualification. diff --git a/docs/tasks/osym-907-debug-control-handoff-and-scheduler-fencing.md b/docs/tasks/osym-907-debug-control-handoff-and-scheduler-fencing.md new file mode 100644 index 000000000..866a5a0cc --- /dev/null +++ b/docs/tasks/osym-907-debug-control-handoff-and-scheduler-fencing.md @@ -0,0 +1,69 @@ +--- +id: OSYM-907 +title: Debug Control Handoff And Scheduler Fencing +milestone: 'M13: ACP Debugging And IDE Attach' +priority: 2 +estimate: 8 +blockedBy: +- OSYM-840 +- OSYM-904 +blocks: +- OSYM-841 +- OSYM-845 +areas: +- debugging +- orchestrator +- acp +parent: null +--- + +## Summary + +Arbitrate exclusive prompt and operator-response ownership between the scheduler and an IDE attachment without allowing concurrent writers or premature cleanup. + +## Scope + +### In scope + +- Add orchestrator-owned observe/acquire/release commands with lease generation fencing and audited outcomes through the existing control plane. +- Acquiring debug control first holds dispatch/retries for the bound issue, then settles or explicitly cancels in-flight work and resolves pending interactions before granting a writer. +- Route permissions/questions to exactly one response owner. Reject late scheduler, IDE and UI decisions after transfer; other issue scheduling continues. +- Release control only after debug-owned work settles. Define cancellation deadlines, EOF, editor crash, stale lease and host restart behavior; retain uncertain execution fences until stopped/reconciled. +- Coordinate workspace retention/cleanup and live-session ownership; terminal and IDE prompts use the same arbitration. + +### Out of scope + +- Multiple simultaneous writers, collaboration between editors, and independent scheduler mutation from UI processes. + +## Deliverables + +- Control handoff state machine and typed commands/events/receipts. +- Race tests and operator-visible busy, held, waiting and uncertain states. + +## Acceptance Criteria + +- [ ] A scheduler turn racing IDE acquisition cannot overlap an IDE prompt; no idle-check/time-of-use window permits a second writer. +- [ ] Only one IDE process obtains control for a session; stale generation commands and duplicate responses fail deterministically. +- [ ] Pending approvals during acquisition/release have a documented single responder and cannot strand a blocked ACP call. +- [ ] EOF, lease expiry or failed cancellation never unblocks scheduler execution merely because the editor disappeared. +- [ ] Closing an attachment cancels its own active work before releasing control/resources; durable workspace/session identity survives, and unrelated work is not cancelled. + +## Test Plan + +- Run deterministic scheduler/IDE race tests for retries, concurrent attach, cancellation, pending permissions, editor crash, restart and cleanup. +- Verify other issue workers continue during debug hold and that handoff receipts reflect actual quiescence. + +## Context + +- docs/specs/opensymphony-acp-debugging-spec.md: control ownership and close/disconnect. +- Existing orchestrator command channels, worker interrupts, terminal-runtime envelope and cleanup/lease fencing; OSYM-901 and OSYM-904 seams. + +## Definition of Ready + +- [ ] Linked specifications and repository contracts have been read. +- [ ] Required dependencies are merged and their evidence is available. +- [ ] The implementation can begin using this task and its referenced sources. + +## Notes + +This is a required safety boundary for multi-harness attachment. Waiting for an idle status without holding the scheduler is insufficient. diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 162b38498..ccc542b58 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -627,6 +627,36 @@ That document covers: - logging, manifests, and recovery inspection - version pinning, CI, and local safety posture +## ACP stdio client tests + +Run `cargo test-system-duckdb --test acp` for the executable client contract and +`cargo test-system-duckdb --lib acp` for central configuration coverage. The reusable +`tests/fixtures/acp_peer.py` peer validates initialize/authenticate/new/prompt, +process/session cwd equality, capability honesty, credential exclusion, +interleaved updates and callbacks, opaque string/zero IDs, unknown notifications, +unknown updates and stop reasons, cancellation acknowledgement/deadline, malformed +and oversized frames, EOF/crash, output floods, bounded stderr/evidence, and a hung +process tree. Regressions cover cancellation in every setup phase, pre-cancelled +launches, JSON-escaped prompt overflow, missing update payloads, preservation of +whitespace and long code chunks, ACP profile migration, environment alias +scoping, post-submission authentication errors, and opaque-ID debug redaction. Current-thread Tokio +tests check callback/cancellation responsiveness, bind a new session before an +adjacent response/update pair is dispatched, and saturate callback output while +a peer stops reading stdin. Count and encoded-byte budgets cover successful, +unknown and invalid permission callbacks; sequential round trips verify that +completed transport writes release reservations. +The `acp-windows` CI job runs `python scripts/validation/check-acp-windows.py` +on Windows. Its temporary Cargo harness compiles the production Windows process +owner and verifies descendant termination on normal teardown, parent exit, +wait deadline, and dropped futures. It also executes the shared environment +replacement helper against a real child to verify case-insensitive alias +precedence. Dependencies are read from the root manifest. On another host, +`--check-target x86_64-pc-windows-gnu` checks compilation with that Rust target +installed; cross-compilation alone does not prove Windows process behavior. +Run `cargo fmt --check` and `cargo clippy-system-duckdb`; dependency changes also +require bundled-mode validation. Vendor interoperability evidence belongs to the +later live-qualification task and must not be inferred from fake-peer tests. + ## Current model diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index f9874e4ba..2027b42fa 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -916,6 +916,26 @@ trait WorkspaceManager { - issue and run metadata file write and reload - conversation reset path preserves workspace safety +## ACP client process ownership + +The executable ACP client takes a host-supplied workspace root, sanitized checkout +key, and canonical issue workspace. Both process cwd and session cwd use that +verified directory; root execution, missing directories and symlink escapes are +rejected before spawn. Profiles cannot supply a cwd. The host supplies an explicit +environment with scoped memory grants and checkout credential exclusions. The +client clears ambient inheritance and rejects profile references that would +reintroduce excluded credentials. + +The initial client owns one process per `run_turn` call and reuses the workspace +process-group teardown helpers. It records local reaping independently of ACP +cancellation acknowledgement. `process_tree_signal_error` records any failed +process-group signal separately from the child exit; consumers must not infer +process-tree quiescence from child reaping alone. Local process termination does not prove delegated +remote work has stopped. Host session retention, durable run identity, recovery +and cleanup fencing are follow-on work in OSYM-901 and OSYM-902; this API does not +persist or replay prompts. Local process access remains trusted host execution, +not a sandbox. + ## Current model diff --git a/scripts/validation/check-acp-windows.py b/scripts/validation/check-acp-windows.py new file mode 100644 index 000000000..9da645b05 --- /dev/null +++ b/scripts/validation/check-acp-windows.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Compile/test the production Windows ACP owner without unrelated CLI dependencies. + +The temporary Cargo harness is not a workspace member or published package. Its +runtime dependency definitions come directly from the root manifest. +""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import tomllib + +parser = argparse.ArgumentParser() +parser.add_argument("--check-target", help="Cross-compile check instead of native Windows tests") +args = parser.parse_args() +if args.check_target and "windows" not in args.check_target: + parser.error("--check-target must name a Windows Rust target") +if not args.check_target and sys.platform != "win32": + parser.error("native process tests require Windows; use --check-target for compilation only") +root = Path(__file__).resolve().parents[2] +config = tomllib.loads((root / "Cargo.toml").read_text()) +deps = { + "process-wrap": config["target"]["cfg(windows)"]["dependencies"]["process-wrap"], + "tokio": config["workspace"]["dependencies"]["tokio"], + "tempfile": config["workspace"]["dependencies"]["tempfile"], +} + +def toml(value): + if isinstance(value, dict): + return "{ " + ", ".join(f"{key} = {toml(val)}" for key, val in value.items()) + " }" + return json.dumps(value) + +with tempfile.TemporaryDirectory(prefix="acp-windows-") as temp: + harness = Path(temp) + (harness / "Cargo.toml").write_text( + '[package]\nname = "acp-windows-validation"\nversion = "0.0.0"\nedition = "2024"\n' + '[lib]\npath = "lib.rs"\n[lints.rust]\nunsafe_code = "forbid"\n[dependencies]\n' + + "\n".join(f"{key} = {toml(value)}" for key, value in deps.items()) + "\n" + ) + (harness / "lib.rs").write_text( + '#![cfg(windows)]\n#[path = ' + + json.dumps(str(root / "crates/opensymphony-acp/src/windows_process.rs")) + + ']\nmod windows_process;\n#[path = ' + + json.dumps(str(root / "crates/opensymphony-workspace/src/environment.rs")) + + ']\nmod environment;\n' + ) + shutil.copyfile(root / "Cargo.lock", harness / "Cargo.lock") + command = ["cargo", "check", "--tests", "--target", args.check_target] if args.check_target else ["cargo", "test"] + subprocess.run(command + ["--manifest-path", str(harness / "Cargo.toml")], check=True) diff --git a/src/lib.rs b/src/lib.rs index df94739e7..04f2b78bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#[path = "../crates/opensymphony-acp/src/lib.rs"] +pub mod opensymphony_acp; #[path = "../crates/opensymphony-cli/src/lib.rs"] pub mod opensymphony_cli; #[path = "../crates/opensymphony-code-intel/src/lib.rs"] diff --git a/tests/acp.rs b/tests/acp.rs new file mode 100644 index 000000000..ba7058335 --- /dev/null +++ b/tests/acp.rs @@ -0,0 +1,1067 @@ +use opensymphony::opensymphony_acp::{ + AcpProfile, ClientError, ClientLimits, LaunchContext, run_turn, +}; +use opensymphony::opensymphony_workflow::{AcpAuth, WorkflowDefinition}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + time::Duration, +}; +use tokio_util::sync::CancellationToken; + +fn profile(mode: &str) -> AcpProfile { + AcpProfile { + command: "python3".into(), + args: vec![ + format!("{}/tests/fixtures/acp_peer.py", env!("CARGO_MANIFEST_DIR")), + mode.into(), + ], + transport: "stdio".into(), + protocol_versions: vec![1], + env_refs: BTreeMap::new(), + auth: Some(AcpAuth { + method_id: "test_auth".into(), + }), + required_capabilities: vec![], + extensions: vec![], + } +} +fn context(root: &Path) -> LaunchContext { + let root = root.canonicalize().expect("canonical root"); + let cwd = root.join("COE-608"); + std::fs::create_dir_all(&cwd).expect("create issue workspace"); + LaunchContext { + workspace_root: root, + workspace_key: "COE-608".into(), + issue_workspace: cwd, + environment: BTreeMap::from([ + ("PATH".into(), std::env::var("PATH").expect("PATH")), + ("CHECKOUT_SECRET".into(), "checkout-secret-value".into()), + ]), + excluded_environment: BTreeSet::from(["CHECKOUT_SECRET".into()]), + } +} +fn limits() -> ClientLimits { + ClientLimits { + setup_timeout: Duration::from_secs(2), + prompt_timeout: Duration::from_secs(2), + cancel_timeout: Duration::from_millis(150), + reap_timeout: Duration::from_secs(2), + ..ClientLimits::default() + } +} + +#[tokio::test] +async fn acp_process_completes_ordered_callbacks_and_redacts_evidence() { + let root = tempfile::tempdir().expect("temp"); + let mut launch = context(root.path()); + launch + .environment + .insert("AUTH_SOURCE".into(), "fake-sensitive-auth-material".into()); + let mut config = profile("complete"); + config + .env_refs + .insert("TEST_AUTH".into(), "AUTH_SOURCE".into()); + let (tx, mut rx) = tokio::sync::mpsc::channel(16); + let run = run_turn( + &config, + launch, + "hello".into(), + CancellationToken::new(), + Some(tx), + limits(), + ) + .await + .expect("launch"); + assert!(!format!("{run:?}").contains("fake-sensitive-auth-material")); + let report = run.outcome.expect("completed protocol"); + assert_eq!(report.stop_reason, "end_turn"); + assert!(!report.cancellation_acknowledged); + assert_eq!(report.session_id, "opaque/session:zero"); + assert!(run.process_reaped); + let mut updates = Vec::new(); + while let Ok(update) = rx.try_recv() { + updates.push(update.update); + } + assert_eq!(updates.last().expect("last")["content"]["text"], "last"); + assert!( + updates + .iter() + .any(|u| u["sessionUpdate"] == "future_update") + ); + let evidence = serde_json::to_string(&run.evidence).expect("JSON"); + assert!(!evidence.contains("fake-sensitive-auth-material")); + assert!(!run.stderr.contains("fake-sensitive-auth-material")); + assert!(evidence.contains("opaque-request")); + assert!(evidence.contains("_future/notice")); + assert!(run.evidence.iter().any(|e| e.direction == "outgoing" + && e.payload["id"] == 0 + && e.payload["error"]["code"] == -32601)); + assert_eq!( + run.evidence + .iter() + .filter(|e| e.direction == "outgoing" && e.payload.get("result").is_some()) + .count(), + 1 + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn acp_binds_new_session_before_adjacent_update_dispatch() { + for _ in 0..12 { + let root = tempfile::tempdir().expect("temp"); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + let run = run_turn( + &profile("adjacent_session_update"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + Some(tx), + limits(), + ) + .await + .expect("launch"); + assert!(run.outcome.expect("ordered session binding").succeeded()); + assert!(run.process_reaped); + let update = rx.recv().await.expect("adjacent update delivered"); + assert_eq!(update.session_id, "opaque/session:zero"); + assert_eq!(update.update["sessionUpdate"], "current_mode_update"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn acp_bounds_callback_output_before_sdk_enqueue_when_stdin_is_blocked() { + for mode in [ + "blocked_callbacks_unknown", + "blocked_callbacks_permission", + "blocked_callbacks_invalid", + ] { + for (callback_frames, callback_bytes) in [(3, 1024 * 1024), (4096, 64 * 1024)] { + let root = tempfile::tempdir().expect("temp"); + let run = tokio::time::timeout( + Duration::from_secs(5), + run_turn( + &profile(mode), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + callback_frames, + callback_bytes, + // The entire fixture fits ingress; only callback egress can saturate. + queued_frames: 4096, + queued_bytes: 64 * 1024 * 1024, + evidence_frames: 0, + ..limits() + }, + ), + ) + .await + .expect("saturation never blocks dispatch") + .expect("launch"); + assert_eq!( + run.outcome.expect_err("output must saturate"), + ClientError::ResourceLimit { submitted: true }, + "{mode}: {callback_frames}/{callback_bytes}" + ); + assert!(run.process_reaped); + } + } +} + +#[tokio::test] +async fn acp_releases_callback_budget_after_transport_flush() { + let root = tempfile::tempdir().expect("temp"); + let run = run_turn( + &profile("paced_queue"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + callback_frames: 1, + callback_bytes: 256, + ..limits() + }, + ) + .await + .expect("launch"); + assert!( + run.outcome + .expect("sixteen callbacks reuse one reservation") + .succeeded() + ); + assert!(run.process_reaped); +} + +#[tokio::test(flavor = "current_thread")] +async fn acp_cancel_waits_for_prompt_response_and_keeps_tokio_responsive() { + for (mode, acknowledged) in [("cancel", true), ("ignore_cancel", false)] { + let root = tempfile::tempdir().expect("temp"); + let token = CancellationToken::new(); + let cancel = token.clone(); + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let cancel_after_update = async { + rx.recv().await.expect("update before cancel"); + cancel.cancel(); + }; + let config = profile(mode); + let (run, ()) = tokio::join!( + run_turn( + &config, + context(root.path()), + "hello".into(), + token, + Some(tx), + limits() + ), + cancel_after_update + ); + let run = run.expect("launch"); + assert!(run.process_reaped); + if acknowledged { + let report = run.outcome.expect("response"); + assert!(report.cancellation_acknowledged); + assert_eq!( + rx.recv().await.expect("last update").update["content"]["text"], + "before cancellation response" + ); + } else { + assert_eq!( + run.outcome.expect_err("must not ack send"), + ClientError::CancelTimeout + ); + } + } +} + +#[tokio::test] +async fn acp_adversarial_frames_deadlines_eof_and_unknown_outcomes() { + for mode in [ + "foreign_session", + "missing_update", + "null_update", + "missing_session_id", + "malformed", + "oversized", + "eof", + "crash", + "flood", + "setup_hang", + "hang", + "v2", + ] { + let root = tempfile::tempdir().expect("temp"); + let mut bound = limits(); + bound.setup_timeout = Duration::from_millis(500); + bound.prompt_timeout = Duration::from_millis(500); + let config = profile(mode); + let result = tokio::time::timeout( + Duration::from_secs(5), + run_turn( + &config, + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + bound, + ), + ) + .await + .expect("bounded run") + .expect("spawn"); + assert!(result.outcome.is_err(), "mode {mode}: {:?}", result); + assert!(result.process_reaped, "mode {mode}: {result:?}"); + if mode == "v2" { + assert!( + result + .outcome + .expect_err("v2 rejected") + .to_string() + .contains("negotiate ACP v1") + ); + } + } + let root = tempfile::tempdir().expect("temp"); + let result = run_turn( + &profile("unknown_stop"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("spawn"); + assert_eq!( + result.outcome.expect("wire response").stop_reason, + "future_stop_reason" + ); +} + +#[tokio::test] +async fn acp_bounds_update_delivery_stderr_and_evidence() { + let root = tempfile::tempdir().expect("temp"); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let result = run_turn( + &profile("complete"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + Some(tx), + limits(), + ) + .await + .expect("spawn"); + assert!( + matches!( + result.outcome, + Err(ClientError::ResourceLimit { submitted: true }) + ), + "{:?}", + result + ); + let result = run_turn( + &profile("stderr_flood"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + evidence_frames: 2, + ..limits() + }, + ) + .await + .expect("spawn"); + assert_eq!(result.outcome.expect("turn").stop_reason, "end_turn"); + assert_eq!(result.evidence.len(), 2); + assert!(result.evidence_truncated); + assert_eq!(result.stderr, "[stderr capture limit exceeded]"); +} + +#[tokio::test] +async fn acp_invalid_launch_inputs_fail_before_execution() { + let root = tempfile::tempdir().expect("temp"); + for case in 0..11 { + let mut config = profile("complete"); + let mut launch = context(root.path()); + let mut bound = limits(); + match case { + 0 => launch.issue_workspace = launch.workspace_root.clone(), + 1 => { + config + .env_refs + .insert("TEST_AUTH".into(), "CHECKOUT_SECRET".into()); + } + 2 => { + config + .env_refs + .insert("TEST_AUTH".into(), "MISSING_ENV".into()); + } + 3 => { + config + .env_refs + .insert("TEST_AUTH".into(), "AUTH_SOURCE".into()); + launch + .environment + .insert("AUTH_SOURCE".into(), "known-secret".into()); + config.args.push("known-secret".into()); + } + 4 => config.args.push("--token=secret".into()), + 5 => bound.evidence_bytes = 16 * 1024 * 1024 + 1, + 6 => bound.queued_bytes = 64 * 1024 * 1024 + 1, + 7 => bound.callback_frames = 0, + 8 => bound.callback_frames = 4097, + 9 => bound.callback_bytes = 255, + _ => bound.callback_bytes = 64 * 1024 * 1024 + 1, + } + assert!( + run_turn( + &config, + launch, + "hello".into(), + CancellationToken::new(), + None, + bound + ) + .await + .is_err() + ); + } + #[cfg(unix)] + { + let outside = tempfile::tempdir().expect("outside"); + let mut launch = context(root.path()); + let link = launch.workspace_root.join("escape"); + std::os::unix::fs::symlink(outside.path(), &link).expect("link"); + launch.workspace_key = "escape".into(); + launch.issue_workspace = link; + assert!(matches!( + run_turn( + &profile("complete"), + launch, + "hello".into(), + CancellationToken::new(), + None, + limits() + ) + .await, + Err(ClientError::InvalidWorkspace) + )); + } +} + +#[test] +fn acp_workflow_profiles_validate_and_preserve_environment_references() { + let source = "---\ntracker:\n kind: linear\n project_slug: project\n active_states: [Todo]\n terminal_states: [Done]\nrouting:\n harness: acp\n harness_profile: fake\nacp:\n profiles:\n fake:\n command: python3\n args: [agent.py]\n env_refs: {TEST_AUTH: AUTH_SOURCE}\n---\nPrompt"; + let env = BTreeMap::from([ + ("LINEAR_API_KEY".into(), "linear-secret".into()), + ("AUTH_SOURCE".into(), "auth-secret".into()), + ]); + let resolved = WorkflowDefinition::parse(source) + .expect("parse") + .resolve(Path::new("/repo"), &env) + .expect("resolve"); + assert_eq!( + resolved.config.routing.harness_profile.as_deref(), + Some("fake") + ); + for harness in ["acp", "openhands_agent_server", "codex_app_server"] { + let mut overrides = env.clone(); + overrides.insert("OPENSYMPHONY_HARNESS".into(), harness.into()); + let overridden = WorkflowDefinition::parse(source) + .expect("parse override") + .resolve(Path::new("/repo"), &overrides) + .expect("resolve explicit harness override"); + assert_eq!(overridden.config.routing.harness, harness); + assert!(overridden.config.routing.harness_from_env); + assert_eq!( + overridden.config.routing.harness_profile.as_deref(), + if harness == "acp" { Some("fake") } else { None } + ); + } + let rendered = serde_yaml::to_string(&resolved.extensions.acp).expect("render"); + assert!(rendered.contains("AUTH_SOURCE")); + assert!(!rendered.contains("auth-secret")); + for (old, new) in [ + ("harness_profile: fake", "harness_profile: missing"), + ("args: [agent.py]", "args: ['--TOKEN=secret']"), + ("args: [agent.py]", "args: ['--Api-Key', 'secret']"), + ("args: [agent.py]", "args: ['--PaSsWoRd=secret']"), + ("args: [agent.py]", "args: ['--SECRET', 'secret']"), + ("args: [agent.py]", "transport: http"), + ("args: [agent.py]", "protocol_versions: [2]"), + ("args: [agent.py]", "extensions: [cursor]"), + ("args: [agent.py]", "required_capabilities: [unknown]"), + ("AUTH_SOURCE", "literal-secret-value"), + ("args: [agent.py]", "auth: {method_id: ''}"), + ("harness: acp", "harness: openhands_agent_server"), + ( + "harness_profile: fake", + "harness_profile: fake\n model: some-model", + ), + ] { + let workflow = + WorkflowDefinition::parse(&source.replace(old, new)).expect("parse invalid semantics"); + assert!(workflow.resolve(Path::new("/repo"), &env).is_err(), "{new}"); + } + for flag in [ + "--access-token", + "--oauth2-bearer", + "--client-secret", + "--pat", + ] { + for args in [ + format!("['{flag}', 'literal-secret']"), + format!("['{}=literal-secret']", flag.to_ascii_uppercase()), + ] { + let workflow = WorkflowDefinition::parse( + &source.replace("args: [agent.py]", &format!("args: {args}")), + ) + .expect("parse credential flags"); + let error = workflow + .resolve(Path::new("/repo"), &env) + .expect_err("credential flags rejected"); + assert!(!error.to_string().contains("literal-secret")); + } + } + let unsupported = WorkflowDefinition::parse(&source.replace("harness: acp", "harness: absent")) + .expect("parse") + .resolve(Path::new("/repo"), &env) + .expect_err("unknown harness"); + assert!(unsupported.to_string().contains("`acp`")); + assert!(WorkflowDefinition::parse(&source.replace("args: [agent.py]", "cwd: /tmp")).is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn acp_unresponsive_process_tree_is_terminated() { + let root = tempfile::tempdir().expect("temp"); + let launch = context(root.path()); + let pid_path = launch.issue_workspace.join("grandchild.pid"); + let result = run_turn( + &profile("hang_tree"), + launch, + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + prompt_timeout: Duration::from_millis(500), + ..limits() + }, + ) + .await + .expect("launch"); + assert_eq!( + result.outcome.expect_err("deadline"), + ClientError::PromptTimeout + ); + assert!(result.process_reaped); + let pid: i32 = std::fs::read_to_string(pid_path) + .expect("descendant launched") + .parse() + .expect("PID"); + let pid = rustix::process::Pid::from_raw(pid).expect("PID"); + // A killed orphan may briefly remain a zombie until init reaps it. + let state = std::process::Command::new("ps") + .args(["-o", "stat=", "-p", &pid.as_raw_nonzero().get().to_string()]) + .output() + .expect("ps"); + let state = String::from_utf8_lossy(&state.stdout); + assert!( + state.trim().is_empty() || state.trim().starts_with('Z'), + "descendant still running: {state}" + ); +} + +#[tokio::test] +async fn acp_required_capability_and_authentication_fail_before_prompt() { + for capability in [ + Some("prompt.image"), + Some("prompt.audio"), + Some("prompt.embedded_context"), + None, + ] { + let root = tempfile::tempdir().expect("temp"); + let mut config = profile("complete"); + if let Some(capability) = capability { + config.required_capabilities = vec![capability.into()]; + } else { + config.auth = Some(AcpAuth { + method_id: "not-advertised".into(), + }); + } + let result = run_turn( + &config, + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("spawn"); + assert!(matches!(result.outcome, Err(ClientError::Setup(_)))); + if let Some(capability) = capability { + assert!( + result + .outcome + .as_ref() + .expect_err("missing capability") + .to_string() + .contains(capability) + ); + } + assert!( + !result + .evidence + .iter() + .any(|frame| frame.payload["method"] == "session/prompt") + ); + } +} + +#[tokio::test] +async fn acp_missing_login_reports_an_actionable_error() { + let root = tempfile::tempdir().expect("temp"); + let mut config = profile("auth_required"); + config.auth = None; + let result = run_turn( + &config, + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("spawn"); + assert_eq!( + result.outcome.expect_err("auth"), + ClientError::AuthenticationRequired { submitted: false } + ); + assert!( + !result + .evidence + .iter() + .any(|frame| frame.payload["method"] == "session/prompt") + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn acp_setup_cancellation_stops_before_prompt_and_pre_cancelled_tokens_do_not_spawn() { + let root = tempfile::tempdir().expect("temp"); + let token = CancellationToken::new(); + token.cancel(); + let mut config = profile("complete"); + config.command = "nonexistent-acp-executable".into(); + assert_eq!( + run_turn( + &config, + context(root.path()), + "hello".into(), + token, + None, + limits() + ) + .await + .expect_err("pre-cancelled launch"), + ClientError::CancelledBeforePrompt + ); + for mode in ["setup_hang", "auth_hang", "new_hang"] { + let root = tempfile::tempdir().expect("temp"); + let launch = context(root.path()); + let marker = launch.issue_workspace.join("setup-waiting"); + let token = CancellationToken::new(); + let cancel = token.clone(); + let config = profile(mode); + let bound = ClientLimits { + setup_timeout: Duration::from_secs(30), + ..limits() + }; + let cancel_at_setup = async { + while !tokio::fs::try_exists(&marker).await.expect("marker") { + tokio::time::sleep(Duration::from_millis(10)).await; + } + cancel.cancel(); + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(3), async { + tokio::join!( + run_turn(&config, launch, "hello".into(), token, None, bound), + cancel_at_setup + ) + }) + .await + .expect("setup cancellation is prompt"); + let result = result.expect("spawn"); + assert_eq!( + result.outcome.expect_err("cancelled setup"), + ClientError::CancelledBeforePrompt + ); + assert!(result.process_reaped); + assert!( + !result + .evidence + .iter() + .any(|frame| frame.payload["method"] == "session/prompt") + ); + } +} + +#[tokio::test] +async fn acp_live_updates_preserve_whitespace_and_long_content() { + let root = tempfile::tempdir().expect("temp"); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + let result = run_turn( + &profile("rich_update"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + Some(tx), + limits(), + ) + .await + .expect("spawn"); + assert!(result.outcome.expect("completed").succeeded()); + assert_eq!( + rx.recv().await.expect("space").update["content"]["text"], + " " + ); + assert_eq!( + rx.recv().await.expect("code").update["content"]["text"], + format!( + "def example():\n\treturn ' spaced '\n{}", + "x".repeat(1024) + ) + ); +} + +#[tokio::test] +async fn acp_rpc_errors_preserve_redacted_context_without_evidence() { + let root = tempfile::tempdir().expect("temp"); + for method in [ + "initialize", + "authenticate", + "session/new", + "session/prompt", + ] { + let mut config = profile(&format!("rpc_{}", method.replace('/', "_"))); + config + .env_refs + .insert("TEST_AUTH".into(), "AUTH_SOURCE".into()); + let mut launch = context(root.path()); + launch + .environment + .insert("AUTH_SOURCE".into(), "rpc-known-sensitive".into()); + let result = run_turn( + &config, + launch, + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + evidence_bytes: 0, + ..limits() + }, + ) + .await + .expect("launch"); + assert!(result.evidence.is_empty()); + assert!(result.process_reaped); + let error = result.outcome.expect_err("peer RPC error"); + assert!(!format!("{error:?} {error}").contains("rpc-known-sensitive")); + assert!(!format!("{error:?} {error}").contains("acct-rpc-sensitive")); + match error { + ClientError::Rpc { + method: failed_method, + code, + message, + submitted, + } => { + assert_eq!(failed_method, method); + assert_eq!(code, -32602); + assert_eq!(submitted, method == "session/prompt"); + assert!(message.starts_with("bad params [redacted]")); + assert!(message.chars().count() <= 515); + } + other => panic!("missing RPC context: {other:?}"), + } + } +} + +#[tokio::test] +async fn acp_configured_environment_targets_follow_platform_name_rules() { + let root = tempfile::tempdir().expect("temp"); + let mut config = profile("complete"); + config.env_refs = BTreeMap::from([ + ("AGENT_TOKEN".into(), "SOURCE_A".into()), + ("agent_token".into(), "SOURCE_B".into()), + ]); + let mut launch = context(root.path()); + launch.environment.extend([ + ("SOURCE_A".into(), "credential-a".into()), + ("SOURCE_B".into(), "credential-b".into()), + ]); + let result = run_turn( + &config, + launch, + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await; + if cfg!(windows) { + assert!(matches!(result, Err(ClientError::InvalidConfiguration(_)))); + } else { + assert!( + result + .expect("POSIX targets are distinct") + .outcome + .expect("turn") + .succeeded() + ); + } +} + +#[tokio::test] +async fn acp_live_input_queue_bounds_bytes_and_releases_dispatched_charges() { + let root = tempfile::tempdir().expect("temp"); + for (mode, succeeds) in [("evidence_flood", false), ("paced_queue", true)] { + let result = run_turn( + &profile(mode), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + frame_bytes: 16384, + queued_frames: 4096, + queued_bytes: 1024, + evidence_frames: 0, + ..limits() + }, + ) + .await + .expect("launch"); + if succeeds { + assert!( + result + .outcome + .expect("repeated round trips release queue bytes") + .succeeded() + ); + } else { + assert_eq!( + result + .outcome + .expect_err("legal frame exceeds live queue byte budget"), + ClientError::ResourceLimit { submitted: true } + ); + } + assert!(result.process_reaped); + } +} + +#[tokio::test] +async fn acp_account_identity_fields_are_redacted_in_evidence_and_live_updates() { + let root = tempfile::tempdir().expect("temp"); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + let result = run_turn( + &profile("account_identity"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + Some(tx), + limits(), + ) + .await + .expect("launch"); + assert!(result.outcome.as_ref().expect("turn").succeeded()); + let evidence = serde_json::to_string(&result.evidence).expect("JSON"); + assert!(!evidence.contains("acct-sensitive")); + assert!(!format!("{result:?}").contains("acct-sensitive")); + let update = rx.recv().await.expect("update"); + assert!(!format!("{update:?}").contains("acct-sensitive")); + let serialized = serde_json::to_string(&update).expect("JSON"); + assert!(!serialized.contains("acct-sensitive")); + let identities = &update.update["identities"][0]; + for key in [ + "account_id", + "account-id", + "accountIdentity", + "accountIdentifier", + "accountID", + "chatgpt-account-id", + "providerAccountIdentity", + ] { + assert_eq!(identities[key], "[redacted]", "{key}"); + } + assert_eq!(identities["account_display_name"], "safe display"); +} + +#[tokio::test] +async fn acp_plain_prompt_above_half_frame_budget_is_admitted() { + let root = tempfile::tempdir().expect("temp"); + let result = run_turn( + &profile("complete"), + context(root.path()), + "x".repeat(2500), + CancellationToken::new(), + None, + ClientLimits { + frame_bytes: 4096, + ..limits() + }, + ) + .await + .expect("prompt fits encoded frame"); + assert!(result.outcome.expect("completed turn").succeeded()); + assert!(result.process_reaped); + assert!( + result + .evidence + .iter() + .any(|frame| frame.payload["method"] == "session/prompt") + ); +} + +#[tokio::test] +async fn acp_retained_evidence_bytes_are_bounded_across_legal_frames() { + let root = tempfile::tempdir().expect("temp"); + for budget in [0, 4096, 16384] { + let result = run_turn( + &profile("evidence_flood"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + evidence_bytes: budget, + ..limits() + }, + ) + .await + .expect("launch"); + assert!( + result + .outcome + .expect("evidence truncation preserves completion") + .succeeded() + ); + let retained_bytes: usize = result + .evidence + .iter() + .map(|frame| serde_json::to_vec(frame).expect("frame JSON").len()) + .sum(); + assert!(retained_bytes <= budget); + assert!(result.evidence_truncated); + assert!(result.evidence.len() < limits().evidence_frames); + assert!(result.process_reaped); + if budget == 0 { + assert!(result.evidence.is_empty()); + } else { + assert!(!result.evidence.is_empty()); + } + } +} + +#[tokio::test] +async fn acp_escaped_prompt_overflow_is_rejected_before_submission() { + let root = tempfile::tempdir().expect("temp"); + let result = run_turn( + &profile("complete"), + context(root.path()), + "\0".repeat(100), + CancellationToken::new(), + None, + ClientLimits { + frame_bytes: 512, + ..limits() + }, + ) + .await + .expect("setup can launch"); + assert_eq!( + result.outcome.expect_err("encoded prompt too large"), + ClientError::ResourceLimit { submitted: false } + ); + assert!(result.process_reaped); + assert!( + !result + .evidence + .iter() + .any(|frame| frame.payload["method"] == "session/prompt") + ); +} + +#[tokio::test] +async fn acp_host_credentials_cannot_be_copied_into_literal_argv() { + let root = tempfile::tempdir().expect("temp"); + let mut config = profile("complete"); + config.args.push("checkout-secret-value".into()); + assert!(matches!( + run_turn( + &config, + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + limits() + ) + .await, + Err(ClientError::InvalidConfiguration(_)) + )); +} + +#[tokio::test] +async fn acp_authentication_errors_retain_post_submission_uncertainty() { + let root = tempfile::tempdir().expect("temp"); + let result = run_turn( + &profile("prompt_auth_required"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("spawn"); + assert_eq!( + result.outcome.expect_err("auth expired"), + ClientError::AuthenticationRequired { submitted: true } + ); + assert!( + result + .evidence + .iter() + .any(|frame| frame.payload["method"] == "session/prompt") + ); +} + +#[tokio::test] +async fn acp_live_update_debug_hides_opaque_session_credentials() { + let root = tempfile::tempdir().expect("temp"); + let mut launch = context(root.path()); + launch + .environment + .insert("AUTH_SOURCE".into(), "opaque-session-secret".into()); + let mut config = profile("secret_session"); + config + .env_refs + .insert("TEST_AUTH".into(), "AUTH_SOURCE".into()); + let (tx, mut rx) = tokio::sync::mpsc::channel(16); + let result = run_turn( + &config, + launch, + "hello".into(), + CancellationToken::new(), + Some(tx), + limits(), + ) + .await + .expect("spawn"); + assert!(!format!("{result:?}").contains("opaque-session-secret")); + assert!(result.outcome.expect("completed").succeeded()); + let update = rx.recv().await.expect("update"); + assert_eq!(update.session_id, "opaque-session-secret"); + assert!(!format!("{update:?}").contains("opaque-session-secret")); +} + +#[tokio::test] +async fn acp_env_reference_sources_are_retained_only_when_explicitly_targeted() { + for mode in ["complete", "preserve_source"] { + let root = tempfile::tempdir().expect("temp"); + let mut launch = context(root.path()); + launch + .environment + .insert("AUTH_SOURCE".into(), "scoped-auth-secret".into()); + let mut config = profile(mode); + config + .env_refs + .insert("TEST_AUTH".into(), "AUTH_SOURCE".into()); + if mode == "preserve_source" { + config + .env_refs + .insert("AUTH_SOURCE".into(), "AUTH_SOURCE".into()); + } + let result = run_turn( + &config, + launch, + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("spawn"); + assert!( + result + .outcome + .expect("child verifies scoped environment") + .succeeded() + ); + } +} diff --git a/tests/fixtures/acp_peer.py b/tests/fixtures/acp_peer.py new file mode 100644 index 000000000..8faba8d85 --- /dev/null +++ b/tests/fixtures/acp_peer.py @@ -0,0 +1,185 @@ +"""Adversarial ACP v1 subprocess. No SDK or provider credentials needed.""" +import json +import os +import subprocess +import sys +import time + +mode = sys.argv[1] +session = os.environ["TEST_AUTH"] if mode == "secret_session" else "opaque/session:zero" +prompt_id = None + + +def send(payload): + print(json.dumps({"jsonrpc": "2.0", **payload}), flush=True) + + +def respond(request, result): + send({"id": request["id"], "result": result}) + + +def update(text): + send({"method": "session/update", "params": {"sessionId": session, + "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}}}) + + +for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + if mode == "rpc_" + str(method).replace("/", "_"): + send({"id": message["id"], "error": {"code": -32602, "message": "bad params " + os.environ["TEST_AUTH"] + " account_id=acct-rpc-sensitive " + "x" * 4096, "data": {"secret": os.environ["TEST_AUTH"]}}}) + continue + if (mode, method) in (("setup_hang", "initialize"), ("auth_hang", "authenticate"), ("new_hang", "session/new")): + with open("setup-waiting", "w") as marker: + marker.write(method) + time.sleep(60) + if method == "initialize": + assert message["params"]["protocolVersion"] == 1 + caps = message["params"].get("clientCapabilities", {}) + assert not caps.get("terminal") + assert not any(caps.get("fs", {}).values()) + assert not caps.get("auth", {}).get("terminal") + respond(message, {"protocolVersion": 2 if mode == "v2" else 1, + "agentInfo": {"name": os.environ.get("TEST_AUTH", "test-peer"), "version": "1"}, + "agentCapabilities": {}, + "authMethods": [{"id": "test_auth", "name": "Fake auth"}]}) + elif method == "authenticate": + assert message["params"]["methodId"] == "test_auth" + respond(message, {}) + elif method == "session/new": + if mode == "auth_required": + send({"id": message["id"], "error": {"code": -32000, "message": "Login required"}}) + continue + assert message["params"]["cwd"] == os.getcwd() + assert "CHECKOUT_SECRET" not in os.environ + if mode == "preserve_source": + assert os.environ["AUTH_SOURCE"] == os.environ["TEST_AUTH"] + else: + assert "AUTH_SOURCE" not in os.environ + if mode == "adjacent_session_update": + frames = [ + {"jsonrpc": "2.0", "id": message["id"], "result": {"sessionId": session}}, + {"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session, "update": {"sessionUpdate": "current_mode_update", "currentModeId": "code"}}}, + ] + sys.stdout.write("".join(json.dumps(frame) + "\n" for frame in frames)) + sys.stdout.flush() + else: + respond(message, {"sessionId": session}) + elif method == "session/prompt": + assert message["params"]["sessionId"] == session + prompt_id = message["id"] + if mode == "adjacent_session_update": + respond(message, {"stopReason": "end_turn"}) + continue + if mode.startswith("blocked_callbacks_"): + # Stop reading stdin while sending legal, paced requests. Each ID is + # much larger on the wire than in memory because it requires escaping. + for index in range(1000): + payload = {"id": str(index) + '\"\\' * 8192, "method": "_unknown/request", "params": {}} + if mode.endswith("permission"): + payload["method"] = "session/request_permission" + payload["params"] = {"sessionId": session, "toolCall": {"toolCallId": "tool"}, "options": []} + if mode.endswith("invalid"): + payload["method"] = "session/request_permission" + send(payload) + time.sleep(0.001) + time.sleep(60) + if mode == "prompt_auth_required": + update("started") + send({"id": prompt_id, "error": {"code": -32000, "message": "Token expired"}}) + continue + if mode in ("missing_update", "null_update", "missing_session_id"): + params = {"sessionId": session} + if mode == "null_update": + params["update"] = None + if mode == "missing_session_id": + params = {"update": {"sessionUpdate": "future_update"}} + send({"method": "session/update", "params": params}) + respond(message, {"stopReason": "end_turn"}) + continue + if mode == "paced_queue": + send({"id": "queued-0", "method": "_unknown/request", "params": {"data": "x" * 256}}) + continue + if mode == "account_identity": + fields = {key: "acct-sensitive" for key in ["account_id", "account-id", "accountIdentity", "accountIdentifier", "accountID", "chatgpt-account-id", "providerAccountIdentity"]} + fields["account_display_name"] = "safe display" + send({"method": "session/update", "params": {"sessionId": session, "update": {"sessionUpdate": "future_update", "identities": [fields]}}}) + respond(message, {"stopReason": "end_turn"}) + continue + if mode == "evidence_flood": + for index in range(16): + send({"method": "_future/evidence", "params": {"index": index, "values": ["small"] * 1024}}) + respond(message, {"stopReason": "end_turn"}) + continue + if mode == "rich_update": + update(" ") + update("def example():\n\treturn ' spaced '\n" + "x" * 1024) + respond(message, {"stopReason": "end_turn"}) + continue + if mode == "foreign_session": + send({"method": "session/update", "params": {"sessionId": "another-session", "update": {"sessionUpdate": "future_update"}}}) + respond(message, {"stopReason": "end_turn"}) + continue + if mode == "eof": + sys.exit(0) + if mode == "crash": + sys.exit(7) + if mode == "malformed": + print("{not JSON", flush=True) + time.sleep(60) + if mode == "oversized": + print("x" * 2000000, flush=True) + time.sleep(60) + if mode == "flood": + for _ in range(50000): + update("flood") + time.sleep(60) + if mode == "stderr_flood": + sys.stderr.write("secret" * 100000) + sys.stderr.flush() + if mode == "hang_tree": + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + with open("grandchild.pid", "w") as pid_file: + pid_file.write(str(child.pid)) + update("first") + if mode in ("cancel", "ignore_cancel", "hang", "hang_tree"): + continue + if mode == "unknown_stop": + respond(message, {"stopReason": "future_stop_reason", "_meta": {"test": True}}) + continue + secret = os.environ.get("TEST_AUTH", "") + if secret: + send({"method": "_future/secret-key", "params": {secret: "redact key as well as value"}}) + update(secret) + sys.stderr.write(secret + "\n") + sys.stderr.flush() + send({"method": "_future/notice", "params": {"unknown": "retained"}}) + send({"method": "session/update", "params": {"sessionId": session, "update": {"sessionUpdate": "future_update", "value": 42}}}) + send({"id": 0, "method": "_unknown/request", "params": {"value": "zero"}}) + send({"id": "opaque-request", "method": "session/request_permission", "params": { + "sessionId": session, "toolCall": {"toolCallId": "tool-1"}, "options": [{"optionId": "opaque-allow", "name": "Allow", "kind": "allow_once"}]}}) + elif method == "session/cancel": + assert "id" not in message + if mode == "cancel": + time.sleep(0.05) + update("before cancellation response") + send({"id": prompt_id, "result": {"stopReason": "cancelled"}}) + elif method is None: + if mode == "paced_queue": + assert message["error"]["code"] == -32601 + index = int(message["id"].removeprefix("queued-")) + 1 + if index < 16: + send({"id": f"queued-{index}", "method": "_unknown/request", "params": {"data": "x" * 256}}) + else: + send({"id": prompt_id, "result": {"stopReason": "end_turn"}}) + elif message["id"] == 0: + assert message["error"]["code"] == -32601 + elif message["id"] == "opaque-request": + assert message["result"]["outcome"]["outcome"] == "cancelled" + update("last") + send({"id": prompt_id, "result": {"stopReason": "end_turn"}}) + else: + raise AssertionError("unexpected response, possibly replied to a notification") + else: + raise AssertionError("unexpected client method") From bca1242eb5d4f745fc66065d2a8c0823b4dbf2b8 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Tue, 22 Sep 2026 06:52:26 -0500 Subject: [PATCH 12/29] docs(memory): capture COE-608 ACP client implementation Capture the merged ACP client issue and synchronize the complete 174-issue memory corpus into managed documentation. Public-doc lint and whitespace checks pass; existing managed history is preserved. --- .opensymphony/memory/memory.yaml | 195 +++++++++++++++++++++++++++++++ docs/accessibility.md | 2 + docs/acp.md | 37 ++++++ docs/build.md | 2 + docs/cli.md | 2 + docs/code-intelligence.md | 2 + docs/code-review.md | 2 + docs/codex-runtime.md | 2 + docs/codex.md | 2 + docs/configuration.md | 2 + docs/control.md | 2 + docs/data-contracts.md | 2 + docs/debugging.md | 2 + docs/desktop.md | 2 + docs/developer-experience.md | 2 + docs/docs.md | 2 + docs/documentation.md | 2 + docs/duckdb.md | 2 + docs/evidence.md | 2 + docs/experiments.md | 3 + docs/frontend.md | 2 + docs/gateway.md | 2 + docs/graph-view.md | 2 + docs/harness-runtime.md | 2 + docs/harness.md | 3 + docs/installer.md | 2 + docs/linear.md | 2 + docs/mcp.md | 2 + docs/memory.md | 2 + docs/migration.md | 2 + docs/model-configuration.md | 2 + docs/okf.md | 2 + docs/openhands-runtime.md | 2 + docs/operations.md | 2 + docs/orchestrator.md | 2 + docs/outcomes.md | 3 + docs/planning.md | 2 + docs/process-protocol.md | 3 + docs/provenance.md | 3 + docs/provider.md | 3 + docs/quality.md | 2 + docs/query-packs.md | 2 + docs/recovery.md | 3 + docs/release.md | 2 + docs/review.md | 3 + docs/run-detail.md | 2 + docs/runner.md | 3 + docs/runtime.md | 2 + docs/rust.md | 2 + docs/scheduler.md | 3 + docs/security.md | 2 + docs/sources.md | 2 + docs/target-repo-bootstrap.md | 2 + docs/task-packets.md | 2 + docs/template.md | 2 + docs/testing.md | 2 + docs/tests.md | 2 + docs/threejs.md | 2 + docs/tui.md | 2 + docs/ui.md | 2 + docs/verifiers.md | 3 + docs/web.md | 2 + docs/workflow.md | 2 + docs/workspace-lifecycle.md | 2 + docs/workspace.md | 3 + 65 files changed, 370 insertions(+) create mode 100644 docs/acp.md diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index d103eb237..895dd07d2 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -29,6 +29,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -65,6 +66,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -88,8 +90,24 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' + acp: + title: Acp + docs_target: docs/acp.md + visibility: public + status: stable + confidence: 95 + aliases: + - acp + source_refs: + linear_labels: + - area:acp + - area:configuration + - area:runtime + linear_milestones: + - 'M12.99: ACP Harness Runtime Adapter' ai-pr-review-human-setup: title: OpenHands PR Review - Human Setup Guide docs_target: docs/ai-pr-review-human-setup.md @@ -149,6 +167,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -225,6 +244,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -241,6 +261,7 @@ areas: - docs/cli.md linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -317,6 +338,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -332,6 +354,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -368,6 +391,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -391,6 +415,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' code-review: @@ -405,6 +430,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -441,6 +467,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -465,6 +492,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' codex: @@ -478,6 +506,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -554,6 +583,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -591,6 +621,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -628,6 +659,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -652,6 +684,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' configuration: @@ -667,6 +700,7 @@ areas: - docs/configuration.md linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -703,6 +737,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -732,6 +767,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M7: Shared Client And Desktop Alpha' @@ -749,6 +785,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -825,6 +862,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -839,6 +877,8 @@ areas: - data-contracts source_refs: linear_labels: + - area:acp + - area:configuration - area:data-contracts - area:evidence - area:experiments @@ -853,6 +893,7 @@ areas: - area:recovery - area:review - area:runner + - area:runtime - area:scheduler - area:task-packets - area:testing @@ -862,6 +903,7 @@ areas: linear_milestones: - 'M0: Foundation And Evidence Contracts' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' debugging: title: Debugging @@ -874,6 +916,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -910,6 +953,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -933,6 +977,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' deployment-modes: @@ -961,6 +1006,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -1037,6 +1083,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1052,6 +1099,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -1127,6 +1175,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1180,6 +1229,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -1256,6 +1306,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1270,6 +1321,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -1306,6 +1358,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -1330,6 +1383,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' duckdb: @@ -1343,6 +1397,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -1419,6 +1474,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1443,6 +1499,8 @@ areas: - evidence source_refs: linear_labels: + - area:acp + - area:configuration - area:evidence - area:experiments - area:harness @@ -1456,6 +1514,7 @@ areas: - area:recovery - area:review - area:runner + - area:runtime - area:scheduler - area:task-packets - area:testing @@ -1465,6 +1524,7 @@ areas: linear_milestones: - 'M0: Foundation And Evidence Contracts' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' experiments: title: Experiments @@ -1476,6 +1536,8 @@ areas: - experiments source_refs: linear_labels: + - area:acp + - area:configuration - area:experiments - area:harness - area:memory @@ -1483,11 +1545,13 @@ areas: - area:provider - area:recovery - area:review + - area:runtime - area:scheduler - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' frontend: title: Frontend @@ -1500,6 +1564,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -1536,6 +1601,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -1559,6 +1625,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' gateway: @@ -1572,6 +1639,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -1648,6 +1716,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1663,6 +1732,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -1699,6 +1769,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -1722,6 +1793,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' harness: @@ -1734,16 +1806,20 @@ areas: - harness source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator - area:provider - area:recovery - area:review + - area:runtime - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' harness-runtime: title: Harness Runtime docs_target: docs/harness-runtime.md @@ -1756,6 +1832,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -1831,6 +1908,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -1923,6 +2001,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -1998,6 +2077,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2012,6 +2092,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -2088,6 +2169,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2117,6 +2199,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -2192,6 +2275,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2212,6 +2296,7 @@ areas: - docs/memory.md linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -2288,6 +2373,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M6.5: Memory Server' @@ -2307,6 +2393,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -2343,6 +2430,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -2366,6 +2454,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' migration-1-0-0: @@ -2391,6 +2480,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -2466,6 +2556,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2480,6 +2571,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -2555,6 +2647,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2598,6 +2691,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -2634,6 +2728,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -2658,6 +2753,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' operations: @@ -2673,6 +2769,7 @@ areas: - docs/operations.md linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -2750,6 +2847,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M6.5: Memory Server' @@ -2769,6 +2867,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -2845,6 +2944,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -2858,6 +2958,8 @@ areas: - outcomes source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator @@ -2865,11 +2967,13 @@ areas: - area:provider - area:recovery - area:review + - area:runtime - area:testing - area:verifiers - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' planning: title: Planning @@ -2882,6 +2986,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -2918,6 +3023,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -2941,6 +3047,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' prerequisites: @@ -2971,6 +3078,8 @@ areas: - process-protocol source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator @@ -2979,10 +3088,12 @@ areas: - area:recovery - area:review - area:runner + - area:runtime - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' provenance: title: Provenance @@ -2994,6 +3105,8 @@ areas: - provenance source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator @@ -3001,11 +3114,13 @@ areas: - area:provider - area:recovery - area:review + - area:runtime - area:task-packets - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' provider: title: Provider @@ -3017,13 +3132,17 @@ areas: - provider source_refs: linear_labels: + - area:acp + - area:configuration - area:orchestrator - area:provider - area:recovery - area:review + - area:runtime - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' quality: title: Quality docs_target: docs/quality.md @@ -3034,6 +3153,8 @@ areas: - quality source_refs: linear_labels: + - area:acp + - area:configuration - area:evidence - area:experiments - area:harness @@ -3047,6 +3168,7 @@ areas: - area:recovery - area:review - area:runner + - area:runtime - area:scheduler - area:task-packets - area:testing @@ -3056,6 +3178,7 @@ areas: linear_milestones: - 'M0: Foundation And Evidence Contracts' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' query-packs: title: Query Packs @@ -3069,6 +3192,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -3105,6 +3229,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -3128,6 +3253,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' recovery: @@ -3140,10 +3266,14 @@ areas: - recovery source_refs: linear_labels: + - area:acp + - area:configuration - area:recovery + - area:runtime - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' release: title: Release docs_target: docs/release.md @@ -3155,6 +3285,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -3191,6 +3322,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -3215,6 +3347,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' repository-layout: @@ -3246,12 +3379,16 @@ areas: - review source_refs: linear_labels: + - area:acp + - area:configuration - area:orchestrator - area:recovery - area:review + - area:runtime - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' run-detail: title: Run Detail docs_target: docs/run-detail.md @@ -3264,6 +3401,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -3300,6 +3438,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -3323,6 +3462,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' runner: @@ -3335,6 +3475,8 @@ areas: - runner source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator @@ -3342,10 +3484,12 @@ areas: - area:recovery - area:review - area:runner + - area:runtime - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' runtime: title: Runtime @@ -3358,6 +3502,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -3434,6 +3579,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3448,6 +3594,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -3484,6 +3631,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -3508,6 +3656,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' scheduler: @@ -3520,17 +3669,21 @@ areas: - scheduler source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator - area:provider - area:recovery - area:review + - area:runtime - area:scheduler - area:testing - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' security: title: Security @@ -3543,6 +3696,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -3619,6 +3773,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3642,6 +3797,7 @@ areas: - 'M12.6: Tree-sitter Code Intelligence' - 'M12.85: Codex Thread Lifecycle' - 'M12.95: Multi-Repository Foundations' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' - 'M6: Gateway And Stream Contract' - 'M9: Collaborative Planning Alpha' @@ -3668,6 +3824,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -3704,6 +3861,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -3727,6 +3885,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' task-packets: @@ -3740,18 +3899,22 @@ areas: - task-packets source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator - area:provider - area:recovery - area:review + - area:runtime - area:task-packets - area:testing - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' template: title: Template @@ -3764,6 +3927,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -3800,6 +3964,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -3823,6 +3988,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' testing: @@ -3836,6 +4002,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -3912,6 +4079,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -3958,6 +4126,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -3994,6 +4163,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -4018,6 +4188,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' threejs: @@ -4031,6 +4202,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -4067,6 +4239,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -4090,6 +4263,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' tui: @@ -4103,6 +4277,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -4178,6 +4353,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -4192,6 +4368,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:build - area:cli - area:code-intelligence @@ -4267,6 +4444,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' - 'M9.5: Developer Build Acceleration' @@ -4295,17 +4473,21 @@ areas: - verifiers source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator - area:provider - area:recovery - area:review + - area:runtime - area:testing - area:verifiers - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' web: title: Web @@ -4318,6 +4500,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -4354,6 +4537,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -4377,6 +4561,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' websocket-runtime: @@ -4405,6 +4590,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -4441,6 +4627,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -4465,6 +4652,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' workspace: @@ -4477,17 +4665,21 @@ areas: - workspace source_refs: linear_labels: + - area:acp + - area:configuration - area:harness - area:memory - area:orchestrator - area:provider - area:recovery - area:review + - area:runtime - area:testing - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M1: Task And Run Orchestration' workspace-and-lifecycle: title: Workspace and Lifecycle @@ -4515,6 +4707,7 @@ areas: source_refs: linear_labels: - area:accessibility + - area:acp - area:cli - area:code-intelligence - area:code-review @@ -4551,6 +4744,7 @@ areas: - area:review - area:run-detail - area:runner + - area:runtime - area:rust - area:scheduler - area:security @@ -4574,6 +4768,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M1: Task And Run Orchestration' redaction: diff --git a/docs/accessibility.md b/docs/accessibility.md index 8fa0585b6..8efb9cd9a 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/acp.md b/docs/acp.md new file mode 100644 index 000000000..f1e9548dc --- /dev/null +++ b/docs/acp.md @@ -0,0 +1,37 @@ +--- +type: topic-doc +area: acp +visibility: public +last_memory_sync: 2026-09-22T11:51:20.924956+00:00 +--- + +# Acp + + + +## Current model + +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) + +## Important invariants + +- Preserve the behavior described in the recent captured changes unless current code and tests show it has changed. +- Use capsule source refs to inspect the original PR or Linear issue when context is ambiguous. + +## Operational flow + +- No generated diagram requested for this sync. + +## Known gotchas + +- No area-specific gotchas were inferred from the selected memory. + +## Recent changes + +- COE-608: ACP Profiles And Executable Protocol Client + +## Source refs + +- COE-608 + + diff --git a/docs/build.md b/docs/build.md index 599711588..96e6cb4fa 100644 --- a/docs/build.md +++ b/docs/build.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/cli.md b/docs/cli.md index 466f686e4..114415236 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index ae3e2df4e..d9c72d22c 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -283,6 +283,7 @@ references, and never returns source snippets. - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -342,5 +343,6 @@ references, and never returns source snippets. - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/code-review.md b/docs/code-review.md index ddbf5dda8..26274d4b1 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index a7ba7546c..7deb97bcd 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/codex.md b/docs/codex.md index 59ebb7a85..0d12d9fad 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/configuration.md b/docs/configuration.md index b09d8ff73..aa21397db 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1171,6 +1171,7 @@ validation before legacy defaults can be applied. - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -1328,5 +1329,6 @@ validation before legacy defaults can be applied. - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/control.md b/docs/control.md index af93daba5..15f997fa2 100644 --- a/docs/control.md +++ b/docs/control.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 12e8ebb10..3b194d057 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -43,6 +43,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -56,5 +57,6 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/debugging.md b/docs/debugging.md index 1bfd910da..f232b1842 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -86,6 +86,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -142,5 +143,6 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/desktop.md b/docs/desktop.md index 8697522c8..d7f4022bc 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -232,6 +233,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 - PR-196 - PR-197 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index 54b498502..9c9e62a4e 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/docs.md b/docs/docs.md index 1347161c1..5738086d7 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client - PR-197: Graph visualization command center and shared fixtures ## Source refs @@ -231,6 +232,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 - PR-197 diff --git a/docs/documentation.md b/docs/documentation.md index 5b3655aba..2509c7624 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/duckdb.md b/docs/duckdb.md index 98fd081ae..1e48594e9 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/evidence.md b/docs/evidence.md index 68d9db66b..14c41e984 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -43,6 +43,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -56,5 +57,6 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/experiments.md b/docs/experiments.md index ec9f95d54..d289471c0 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-566 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-566: Implement configurable run matrices and scheduling +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-555 - COE-556 - COE-566 +- COE-608 diff --git a/docs/frontend.md b/docs/frontend.md index 422190b03..00e65e5f0 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -152,6 +153,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 - PR-196 - PR-197 diff --git a/docs/gateway.md b/docs/gateway.md index ed76e11db..1eb569364 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -151,6 +151,7 @@ baseline, not an issue-workspace overlay. - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client - PR-196: Desktop task and diff latency plus live-refresh UX fixes ## Source refs @@ -252,6 +253,7 @@ baseline, not an issue-workspace overlay. - COE-565 - COE-566 - COE-567 +- COE-608 - PR-196 diff --git a/docs/graph-view.md b/docs/graph-view.md index 4f5713303..d2d634b08 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -150,6 +151,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 - PR-196 - PR-197 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index 1569e031f..436aa5584 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/harness.md b/docs/harness.md index 074e7f2a1..72dad6bab 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -15,6 +15,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-554 contributed: PR #237: feat: add restart-safe parent integration controller (merge `b4e1b67`) - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -35,6 +36,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -42,5 +44,6 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-554 - COE-555 - COE-556 +- COE-608 diff --git a/docs/installer.md b/docs/installer.md index f8b3831e9..e65916f64 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/linear.md b/docs/linear.md index 405153a1b..7cb7dff4c 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/mcp.md b/docs/mcp.md index 956cefa3f..470ea3f6f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/memory.md b/docs/memory.md index a233c096b..099e15ac7 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -843,6 +843,7 @@ are covered by the same archive operation. - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -1008,5 +1009,6 @@ are covered by the same archive operation. - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/migration.md b/docs/migration.md index 31ece3a02..d0a76eead 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,6 +86,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -142,5 +143,6 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index 2698b6145..ad0317407 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/okf.md b/docs/okf.md index 046775ec5..6fbc7ada6 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index 48b0a8e70..e441bd8ca 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/operations.md b/docs/operations.md index 4a4e28a7d..6c8d22c0a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1131,6 +1131,7 @@ after possible submission are uncertain and are never retried by this client. - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -1299,5 +1300,6 @@ after possible submission are uncertain and are never retried by this client. - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index bcacc4fb6..0288da51d 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/outcomes.md b/docs/outcomes.md index 4ae0c72d4..bc3098e9b 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-564 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-564: Implement verifier execution and outcome records +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-555 - COE-556 - COE-564 +- COE-608 diff --git a/docs/planning.md b/docs/planning.md index 6e877647a..356e89cd3 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index 69ea792c4..4ee97bd3a 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-567 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-555 - COE-556 - COE-567 +- COE-608 diff --git a/docs/provenance.md b/docs/provenance.md index 44edd88f6..ce2818dd7 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-563 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-563: Implement task-packet admission and freeze tooling +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-555 - COE-556 - COE-563 +- COE-608 diff --git a/docs/provider.md b/docs/provider.md index 2a1abab94..5ad8aa669 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -31,10 +32,12 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs - COE-555 - COE-556 +- COE-608 diff --git a/docs/quality.md b/docs/quality.md index 7efefbca9..ddd412cec 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -43,6 +43,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -56,5 +57,6 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/query-packs.md b/docs/query-packs.md index 1d5918406..f6f74694f 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/recovery.md b/docs/recovery.md index e9a2f475d..def7f1d61 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 ## Current model - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -29,9 +30,11 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 ## Recent changes - COE-556: Bottom-Up Subtree Cleanup And Recovery +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs - COE-556 +- COE-608 diff --git a/docs/release.md b/docs/release.md index a60d7b555..dee0ae193 100644 --- a/docs/release.md +++ b/docs/release.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -144,6 +145,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 ## 2.11 Rust toolchain boundary diff --git a/docs/review.md b/docs/review.md index cbfaaa732..b3fd94592 100644 --- a/docs/review.md +++ b/docs/review.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -31,10 +32,12 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs - COE-555 - COE-556 +- COE-608 diff --git a/docs/run-detail.md b/docs/run-detail.md index a71321413..e723ab787 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -144,5 +145,6 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/runner.md b/docs/runner.md index d7dd27105..c2ac8ded0 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-567 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-555 - COE-556 - COE-567 +- COE-608 diff --git a/docs/runtime.md b/docs/runtime.md index 61593ec96..113bf1feb 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/rust.md b/docs/rust.md index 3f9a15802..ffbcf369b 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/scheduler.md b/docs/scheduler.md index 098fb66a1..28164ec0b 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-566 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-566: Implement configurable run matrices and scheduling +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-555 - COE-556 - COE-566 +- COE-608 diff --git a/docs/security.md b/docs/security.md index 1c5aa5d8e..813391346 100644 --- a/docs/security.md +++ b/docs/security.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/sources.md b/docs/sources.md index 89d1a13e7..1ab737112 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -353,6 +353,7 @@ Implementation and tests are in `crates/opensymphony-acp/src/lib.rs` and `tests/ - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-563: Implement task-packet admission and freeze tooling +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -434,5 +435,6 @@ Implementation and tests are in `crates/opensymphony-acp/src/lib.rs` and `tests/ - COE-550 - COE-551 - COE-563 +- COE-608 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index 178f41781..fa23436cd 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -86,6 +86,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -142,5 +143,6 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/task-packets.md b/docs/task-packets.md index 9c995282c..0453483e3 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-563: Implement task-packet admission and freeze tooling - COE-565: Implement isolated workspace materialization +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-556 - COE-563 - COE-565 +- COE-608 diff --git a/docs/template.md b/docs/template.md index ef8b315eb..c9b0d49fa 100644 --- a/docs/template.md +++ b/docs/template.md @@ -86,6 +86,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -142,5 +143,6 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/testing.md b/docs/testing.md index 281c009bb..d741a1346 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -228,6 +229,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 - PR-196 - PR-197 diff --git a/docs/tests.md b/docs/tests.md index cf1867b8f..e858af331 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/threejs.md b/docs/threejs.md index 45aaf1127..345ee1561 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/tui.md b/docs/tui.md index 9d7021226..769bf45bc 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -220,5 +221,6 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/ui.md b/docs/ui.md index 78b40f6b7..049c18e19 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -125,6 +125,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -222,6 +223,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 - PR-196 - PR-197 diff --git a/docs/verifiers.md b/docs/verifiers.md index 4959027cb..0145fe49b 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-564 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-564: Implement verifier execution and outcome records +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-555 - COE-556 - COE-564 +- COE-608 diff --git a/docs/web.md b/docs/web.md index 80282f1ee..770721318 100644 --- a/docs/web.md +++ b/docs/web.md @@ -86,6 +86,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -142,5 +143,6 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/workflow.md b/docs/workflow.md index 04a0c22af..88f531c87 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index dd42f7836..091d25496 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -86,6 +86,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-565: Implement isolated workspace materialization - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -142,5 +143,6 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-565 - COE-566 - COE-567 +- COE-608 diff --git a/docs/workspace.md b/docs/workspace.md index abf276886..bc0fec599 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-565 contributed: No merged PR source was matched during capture. +- COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-565: Implement isolated workspace materialization +- COE-608: ACP Profiles And Executable Protocol Client ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-555 - COE-556 - COE-565 +- COE-608 From 5b67134f77e03b98ead8a8fdd707b2b9f504b684 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Tue, 22 Sep 2026 07:49:45 -0500 Subject: [PATCH 13/29] feat(acp): retain host-owned sessions with durable recovery (#243) Keep one bounded, supervised ACP owner across worker attempts and expose private generation-fenced observation commands and source events. Persist identity, process ownership and submission markers before launch or send, then gate recovery on negotiated load/resume and known terminal outcomes. Preserve native manifests, ordered SDK dispatch, workspace containment and truthful remote-quiescence evidence. Add executable peer and real host-crash coverage for retention, replay, cancellation and uncertainty. Validation: full system-DuckDB suite 2116 passed, 4 existing ignored; final library and ACP regression targets 1425 passed; Clippy and format checks clean. No dependency or release metadata changes. --- crates/opensymphony-acp/src/durable.rs | 844 ++++++++++++++++ crates/opensymphony-acp/src/host.rs | 929 ++++++++++++++++++ crates/opensymphony-acp/src/lib.rs | 127 ++- .../opensymphony-acp/src/windows_process.rs | 1 - .../src/orchestrator_run/backends.rs | 1 + crates/opensymphony-control/src/acp.rs | 140 +++ crates/opensymphony-control/src/lib.rs | 19 +- crates/opensymphony-workspace/src/lib.rs | 1 + crates/opensymphony-workspace/src/manager.rs | 2 +- crates/opensymphony-workspace/src/models.rs | 65 ++ docs/architecture.md | 17 +- docs/configuration.md | 10 +- docs/operations.md | 19 + docs/testing-and-operations.md | 10 + docs/workspace-and-lifecycle.md | 35 +- tests/acp_session_host.rs | 669 +++++++++++++ tests/fixtures/acp_session_peer.py | 65 ++ 17 files changed, 2916 insertions(+), 38 deletions(-) create mode 100644 crates/opensymphony-acp/src/durable.rs create mode 100644 crates/opensymphony-acp/src/host.rs create mode 100644 crates/opensymphony-control/src/acp.rs create mode 100644 tests/acp_session_host.rs create mode 100644 tests/fixtures/acp_session_peer.py diff --git a/crates/opensymphony-acp/src/durable.rs b/crates/opensymphony-acp/src/durable.rs new file mode 100644 index 000000000..4473b1b28 --- /dev/null +++ b/crates/opensymphony-acp/src/durable.rs @@ -0,0 +1,844 @@ +//! One durable conversation record and one stable cross-process owner lock. +use std::{fs::File, io, path::Path}; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use super::AcpProfile; +use crate::opensymphony_workspace::{ + AcpProcessState, AcpRecovery, AcpSessionIdentity, AcpSessionState, AcpSessionStatus, + ConversationManifest, WorkspaceError, WorkspaceHandle, WorkspaceManager, +}; + +const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024; +const MAX_ID_BYTES: usize = 4096; + +#[derive(Debug, thiserror::Error)] +pub(super) enum DurabilityError { + #[error("ACP session already has a live owner")] + Owned, + #[error("ACP prior prompt outcome is uncertain; resolve execution risk before another attempt")] + Uncertain, + #[error( + "ACP owner was lost during process launch; resolve process ownership before another launch" + )] + ProcessUncertain, + #[error("ACP cannot replace a native conversation manifest")] + NativeManifest, + #[error("ACP durable session binding mismatch: {0}")] + Binding(&'static str), + #[error("ACP durable metadata exceeds its bounds or contains an invalid identifier")] + InvalidMetadata, + #[error("ACP durable filesystem operation failed ({0:?})")] + Io(io::ErrorKind), + #[error(transparent)] + Workspace(#[from] WorkspaceError), +} + +impl From for DurabilityError { + fn from(error: io::Error) -> Self { + Self::Io(error.kind()) + } +} + +pub(super) fn profile_fingerprint(profile: &AcpProfile) -> Result { + let encoded = serde_json::to_vec(profile).map_err(|_| DurabilityError::InvalidMetadata)?; + Ok(format!("{:x}", Sha256::digest(encoded))) +} + +pub(super) struct Durability { + manager: WorkspaceManager, + workspace: WorkspaceHandle, + manifest: ConversationManifest, + // Never unlink this file: another process may already be waiting on its inode. + _owner_lock: File, +} + +impl Durability { + /// Zero generation is the trusted host's request to claim the latest record. + /// A nonzero generation requires an exact match before reserving its successor. + pub(super) async fn open( + manager: WorkspaceManager, + workspace: WorkspaceHandle, + identity: AcpSessionIdentity, + ) -> Result { + validate_identity(&identity)?; + let issue = manager + .load_issue_manifest(&workspace) + .await? + .ok_or(DurabilityError::Binding("issue manifest missing"))?; + if issue.issue_id != workspace.issue_id() + || issue.identifier != workspace.identifier() + || issue.sanitized_workspace_key != workspace.workspace_key() + || issue.workspace_path != workspace.workspace_path() + || identity.workspace_path != workspace.workspace_path() + { + return Err(DurabilityError::Binding("workspace")); + } + if issue + .repository_binding + .as_ref() + .and_then(|binding| binding.resolved_binding()) + != identity.repository_binding.as_ref() + { + return Err(DurabilityError::Binding("repository")); + } + if workspace.checkout_generation() != identity.checkout_generation.as_deref() { + return Err(DurabilityError::Binding("checkout generation")); + } + + let lock_path = workspace.metadata_dir().join("acp-owner.lock"); + let lock_path = manager + .validate_workspace_owned_path(&workspace, &lock_path) + .await?; + let owner_lock = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + match owner_lock.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return Err(DurabilityError::Owned), + Err(std::fs::TryLockError::Error(error)) => return Err(error.into()), + } + match tokio::fs::symlink_metadata(workspace.conversation_manifest_path()).await { + Ok(metadata) if metadata.len() > MAX_MANIFEST_BYTES as u64 => { + return Err(DurabilityError::InvalidMetadata); + } + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error.into()), + _ => {} + } + let mut manifest = match manager.load_conversation_manifest(&workspace).await? { + Some(manifest) => manifest, + None => { + let mut manifest = ConversationManifest::new( + &workspace, + "", + "acp://stdio", + workspace.metadata_dir(), + "acp-v1", + ); + manifest.acp = Some(AcpSessionState { + harness: "acp".into(), + identity: AcpSessionIdentity { + generation: 0, + ..identity.clone() + }, + session_id: None, + initialization: Value::Null, + status: AcpSessionStatus::Ready, + stop_reason: None, + recovery: AcpRecovery::Fresh, + owner_id: String::new(), + process: AcpProcessState::Stopped, + }); + manifest + } + }; + if manifest.issue_id != workspace.issue_id() + || manifest.identifier != workspace.identifier() + { + return Err(DurabilityError::Binding("issue")); + } + if let Some(run) = manager.load_run_manifest(&workspace).await? { + if run.run_id != identity.run_id || run.attempt != identity.attempt { + return Err(DurabilityError::Binding("run identity")); + } + if let Some(envelope) = run.runtime_envelope { + if envelope.harness != "acp" + || envelope.checkout_path != identity.workspace_path + || Some(&envelope.repository_binding) != identity.repository_binding.as_ref() + || Some(envelope.checkout_generation.as_str()) + != identity.checkout_generation.as_deref() + { + return Err(DurabilityError::Binding("runtime envelope")); + } + manifest.runtime_envelope = Some(envelope); + } + } + let state = manifest + .acp + .as_mut() + .ok_or(DurabilityError::NativeManifest)?; + validate_binding(&state.identity, &identity)?; + if state.harness != "acp" + || manifest.conversation_id != state.session_id.as_deref().unwrap_or_default() + { + return Err(DurabilityError::Binding("harness/session")); + } + if matches!( + state.status, + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain + ) { + return Err(DurabilityError::Uncertain); + } + verify_prior_process(state.process)?; + if identity.generation != 0 && identity.generation != state.identity.generation { + return Err(DurabilityError::Binding("connection generation")); + } + state.identity.generation = state + .identity + .generation + .checked_add(1) + .ok_or(DurabilityError::Binding("connection generation exhausted"))?; + state.identity.run_id = identity.run_id; + state.identity.attempt = identity.attempt; + state.owner_id = uuid::Uuid::new_v4().to_string(); + state.process = AcpProcessState::LaunchPending; + let durable = Self { + manager, + workspace, + manifest, + _owner_lock: owner_lock, + }; + // Claim the generation before launching a process or establishing a connection. + durable.persist().await?; + Ok(durable) + } + + pub(super) fn state(&self) -> &AcpSessionState { + self.manifest + .acp + .as_ref() + .expect("ACP manifest initialized on open") + } + + pub(super) fn state_mut(&mut self) -> &mut AcpSessionState { + self.manifest + .acp + .as_mut() + .expect("ACP manifest initialized on open") + } + + pub(super) async fn persist(&self) -> Result<(), DurabilityError> { + validate_identity(&self.state().identity)?; + if self + .state() + .session_id + .as_deref() + .is_some_and(|id| !valid_id(id)) + || serde_json::to_vec_pretty(&self.manifest) + .map_err(|_| DurabilityError::InvalidMetadata)? + .len() + > MAX_MANIFEST_BYTES + { + return Err(DurabilityError::InvalidMetadata); + } + let path = self.workspace.conversation_manifest_path(); + let mut manifest = self.manifest.clone(); + if let Some(envelope) = &mut manifest.runtime_envelope { + envelope.acp_session = Some(self.state().identity.clone()); + envelope.conversation_binding = self.state().session_id.clone(); + envelope.run_id.clone_from(&self.state().identity.run_id); + envelope.attempt = self.state().identity.attempt; + } + self.manager + .write_json_artifact_atomically(&self.workspace, &path, &manifest) + .await?; + // Atomic replacement alone does not establish the pre-send durability barrier. + let file = tokio::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .await?; + file.sync_all().await?; + sync_directory(&self.workspace.metadata_dir()).await?; + sync_directory(self.workspace.workspace_path()).await?; + Ok(()) + } + + pub(super) async fn launched(&mut self, pid: u32) -> Result<(), DurabilityError> { + if self.state().process != AcpProcessState::LaunchPending || pid == 0 { + return Err(DurabilityError::InvalidMetadata); + } + self.state_mut().process = AcpProcessState::Running { pid }; + self.persist().await + } + + /// Called only after supervised termination and reaping prove local quiescence. + pub(super) async fn stopped(&mut self) -> Result<(), DurabilityError> { + self.state_mut().process = AcpProcessState::Stopped; + self.persist().await + } + + pub(super) async fn ready( + &mut self, + session_id: String, + initialization: Value, + recovery: AcpRecovery, + reset_reason: Option, + ) -> Result<(), DurabilityError> { + self.require_quiescent()?; + if !valid_id(&session_id) { + return Err(DurabilityError::InvalidMetadata); + } + self.manifest.conversation_id = session_id.clone(); + self.manifest.fresh_conversation = recovery == AcpRecovery::Fresh; + self.manifest.reset_reason = reset_reason; + self.manifest.last_attached_at = Some(chrono::Utc::now()); + let state = self.state_mut(); + state.session_id = Some(session_id); + state.initialization = initialization; + state.recovery = recovery; + state.status = AcpSessionStatus::Ready; + state.stop_reason = None; + self.persist().await + } + + pub(super) async fn submitted( + &mut self, + run_id: String, + attempt: u32, + ) -> Result<(), DurabilityError> { + self.require_quiescent()?; + if !valid_id(&run_id) || self.state().session_id.is_none() { + return Err(DurabilityError::InvalidMetadata); + } + let state = self.state_mut(); + state.identity.run_id = run_id; + state.identity.attempt = attempt; + state.status = AcpSessionStatus::Submitted; + state.stop_reason = None; + self.persist().await + } + + pub(super) async fn finished(&mut self, stop_reason: String) -> Result<(), DurabilityError> { + if self.state().status != AcpSessionStatus::Submitted || !valid_id(&stop_reason) { + return Err(DurabilityError::InvalidMetadata); + } + self.manifest.fresh_conversation = false; + let state = self.state_mut(); + state.status = AcpSessionStatus::Finished; + state.stop_reason = Some(stop_reason); + self.persist().await + } + + pub(super) async fn uncertain(&mut self) -> Result<(), DurabilityError> { + self.state_mut().status = AcpSessionStatus::Uncertain; + self.persist().await + } + + fn require_quiescent(&self) -> Result<(), DurabilityError> { + match self.state().status { + AcpSessionStatus::Ready | AcpSessionStatus::Finished => Ok(()), + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain => { + Err(DurabilityError::Uncertain) + } + } + } +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= MAX_ID_BYTES && !id.chars().any(char::is_control) +} + +fn verify_prior_process(process: AcpProcessState) -> Result<(), DurabilityError> { + let pid = match process { + AcpProcessState::Stopped => return Ok(()), + AcpProcessState::LaunchPending => return Err(DurabilityError::ProcessUncertain), + AcpProcessState::Running { pid } => pid, + }; + #[cfg(unix)] + { + let group = i32::try_from(pid) + .ok() + .and_then(rustix::process::Pid::from_raw) + .ok_or(DurabilityError::InvalidMetadata)?; + // Only a missing process group proves local quiescence. EPERM and PID + // reuse remain fenced; the lock alone does not prove an orphan exited. + match rustix::process::test_kill_process_group(group) { + Err(rustix::io::Errno::SRCH) => Ok(()), + _ => Err(DurabilityError::Owned), + } + } + #[cfg(windows)] + { + // The mandatory kill-on-close JobObject ends children on owner death. + let _ = pid; + Ok(()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + Err(DurabilityError::Owned) + } +} + +fn validate_identity(identity: &AcpSessionIdentity) -> Result<(), DurabilityError> { + if !valid_id(&identity.profile_id) + || !valid_id(&identity.profile_fingerprint) + || !valid_id(&identity.credential_scope) + || !valid_id(&identity.run_id) + || identity + .checkout_generation + .as_deref() + .is_some_and(|id| !valid_id(id)) + || !identity.workspace_path.is_absolute() + { + return Err(DurabilityError::InvalidMetadata); + } + Ok(()) +} + +fn validate_binding( + stored: &AcpSessionIdentity, + requested: &AcpSessionIdentity, +) -> Result<(), DurabilityError> { + for (matches, name) in [ + (stored.profile_id == requested.profile_id, "profile"), + ( + stored.profile_fingerprint == requested.profile_fingerprint, + "profile fingerprint", + ), + ( + stored.credential_scope == requested.credential_scope, + "credential scope", + ), + ( + stored.workspace_path == requested.workspace_path, + "workspace", + ), + ( + stored.repository_binding == requested.repository_binding, + "repository", + ), + ( + stored.checkout_generation == requested.checkout_generation, + "checkout generation", + ), + ] { + if !matches { + return Err(DurabilityError::Binding(name)); + } + } + Ok(()) +} + +async fn sync_directory(path: &Path) -> Result<(), DurabilityError> { + #[cfg(unix)] + tokio::fs::File::open(path).await?.sync_all().await?; + // Windows workspace replacement uses MoveFileExW(MOVEFILE_WRITE_THROUGH). + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opensymphony_workspace::{ + CleanupConfig, HookConfig, IssueDescriptor, WorkspaceManagerConfig, + }; + use serde_json::json; + + fn manager(root: &Path) -> WorkspaceManager { + WorkspaceManager::new(WorkspaceManagerConfig { + root: root.to_path_buf(), + hooks: HookConfig::default(), + cleanup: CleanupConfig::default(), + }) + .expect("manager") + } + + async fn workspace(root: &Path) -> WorkspaceHandle { + manager(root) + .ensure(&IssueDescriptor { + issue_id: "issue-609".into(), + identifier: "COE-609".into(), + title: "durability".into(), + current_state: "In Progress".into(), + last_seen_tracker_refresh_at: None, + repository_binding: None, + }) + .await + .expect("workspace") + .handle + } + + fn identity(workspace: &WorkspaceHandle) -> AcpSessionIdentity { + AcpSessionIdentity { + profile_id: "agent".into(), + profile_fingerprint: "a".repeat(64), + credential_scope: "grant-revision-1".into(), + workspace_path: workspace.workspace_path().to_path_buf(), + repository_binding: None, + checkout_generation: None, + generation: 0, + run_id: "run-1".into(), + attempt: 1, + } + } + + #[tokio::test] + async fn owner_claim_is_exclusive_across_processes_and_reserves_generation() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let input = identity(&workspace); + let mut first = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("owner"); + assert_eq!(first.state().identity.generation, 1); + assert!(matches!( + Durability::open(manager(root.path()), workspace.clone(), input.clone()).await, + Err(DurabilityError::Owned) + )); + let output = tokio::time::timeout( + std::time::Duration::from_secs(10), + tokio::process::Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "opensymphony_acp::durable::tests::owner_lock_child", + "--nocapture", + ]) + .env( + "ACP_TEST_OWNER_LOCK", + workspace.metadata_dir().join("acp-owner.lock"), + ) + .output(), + ) + .await + .expect("child deadline") + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); + first.stopped().await.expect("first owner quiescent"); + drop(first); + let mut second = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("next owner"); + assert_eq!(second.state().identity.generation, 2); + second.stopped().await.expect("second owner quiescent"); + drop(second); + let mut stale = input; + stale.generation = 1; + assert!(matches!( + Durability::open(manager(root.path()), workspace, stale).await, + Err(DurabilityError::Binding("connection generation")) + )); + } + + #[test] + fn owner_lock_child() { + let Some(path) = std::env::var_os("ACP_TEST_OWNER_LOCK") else { + return; + }; + let file = File::options() + .read(true) + .write(true) + .open(path) + .expect("lock file"); + assert!(matches!( + file.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + } + + #[tokio::test] + async fn crash_during_launch_retains_an_explicit_ownership_fence() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let input = identity(&workspace); + let owner = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("owner"); + assert_eq!(owner.state().process, AcpProcessState::LaunchPending); + drop(owner); + assert!(matches!( + Durability::open(manager(root.path()), workspace, input).await, + Err(DurabilityError::ProcessUncertain) + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn owner_loss_refuses_a_surviving_process_group_and_allows_a_dead_one() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let input = identity(&workspace); + let mut owner = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("owner"); + let mut command = tokio::process::Command::new("sleep"); + command.arg("30").kill_on_drop(true); + crate::opensymphony_workspace::configure_process_group(&mut command); + let mut child = command.spawn().expect("process group"); + owner + .launched(child.id().expect("child pid")) + .await + .expect("launch checkpoint"); + drop(owner); + assert!(matches!( + Durability::open(manager(root.path()), workspace.clone(), input.clone()).await, + Err(DurabilityError::Owned) + )); + child.kill().await.expect("stop and reap orphan"); + let mut recovered = Durability::open(manager(root.path()), workspace, input) + .await + .expect("dead group can recover"); + assert_eq!(recovered.state().identity.generation, 2); + recovered.stopped().await.expect("release reservation"); + } + + #[tokio::test] + async fn submission_is_durable_and_blocks_recovery_until_terminal_evidence() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let input = identity(&workspace); + let mut owner = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("owner"); + owner + .ready( + "opaque/session:1".into(), + json!({"protocolVersion": 1}), + AcpRecovery::Fresh, + None, + ) + .await + .expect("ready"); + owner + .submitted("run-2".into(), 2) + .await + .expect("submission barrier"); + let stored = manager(root.path()) + .load_conversation_manifest(&workspace) + .await + .expect("load") + .expect("manifest") + .acp + .expect("ACP"); + assert_eq!(stored.status, AcpSessionStatus::Submitted); + assert_eq!(stored.identity.run_id, "run-2"); + assert_eq!(stored.identity.attempt, 2); + assert!(matches!( + owner + .ready( + "replacement".into(), + Value::Null, + AcpRecovery::Fresh, + Some("reset".into()) + ) + .await, + Err(DurabilityError::Uncertain) + )); + drop(owner); + assert!(matches!( + Durability::open(manager(root.path()), workspace.clone(), input.clone()).await, + Err(DurabilityError::Uncertain) + )); + + // An explicit host resolution updates the existing record, preserving identity. + let mut manifest = manager(root.path()) + .load_conversation_manifest(&workspace) + .await + .expect("load") + .expect("manifest"); + manifest.acp.as_mut().expect("ACP").status = AcpSessionStatus::Finished; + manifest.acp.as_mut().expect("ACP").process = AcpProcessState::Stopped; + manager(root.path()) + .write_json_artifact_atomically( + &workspace, + &workspace.conversation_manifest_path(), + &manifest, + ) + .await + .expect("resolve"); + let mut restored = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("resolved owner"); + assert_eq!( + restored.state().session_id.as_deref(), + Some("opaque/session:1") + ); + restored + .ready( + "opaque/session:1".into(), + json!({"protocolVersion": 1}), + AcpRecovery::RestoredLoad, + None, + ) + .await + .expect("load ready"); + restored + .submitted("run-3".into(), 3) + .await + .expect("next submission"); + restored + .finished("end_turn".into()) + .await + .expect("terminal evidence"); + restored.stopped().await.expect("supervised process reaped"); + drop(restored); + let mut reopened = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("finished recovery"); + assert_eq!(reopened.state().status, AcpSessionStatus::Finished); + assert_eq!(reopened.state().stop_reason.as_deref(), Some("end_turn")); + reopened.uncertain().await.expect("explicit risk fence"); + drop(reopened); + assert!(matches!( + Durability::open(manager(root.path()), workspace, input).await, + Err(DurabilityError::Uncertain) + )); + } + + #[tokio::test] + async fn reuse_checks_profile_scope_workspace_repository_and_checkout_binding() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let input = identity(&workspace); + let mut owner = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("owner"); + owner.stopped().await.expect("owner quiescent"); + drop(owner); + for field in [ + "profile", + "profile fingerprint", + "credential scope", + "workspace", + "repository", + "checkout generation", + ] { + let mut changed = input.clone(); + match field { + "profile" => changed.profile_id.push('2'), + "profile fingerprint" => changed.profile_fingerprint.push('2'), + "credential scope" => changed.credential_scope.push('2'), + "workspace" => changed.workspace_path.push("other"), + "repository" => changed.repository_binding = Some(serde_json::from_value(json!({ + "alias": "other", + "repository": {"id": "github:repository:other", "safe_remote_fingerprint": "sha256:other"}, + "config_generation": "config-1", + "inventory_generation": "inventory-1" + })).expect("repository binding")), + "checkout generation" => changed.checkout_generation = Some("checkout-2".into()), + _ => unreachable!(), + } + assert!( + matches!(Durability::open(manager(root.path()), workspace.clone(), changed).await, Err(DurabilityError::Binding(name)) if name == field), + "{field}" + ); + } + let mut manifest = manager(root.path()) + .load_conversation_manifest(&workspace) + .await + .expect("load") + .expect("manifest"); + manifest.issue_id = "another-issue".into(); + manager(root.path()) + .write_conversation_manifest(&workspace, &manifest) + .await + .expect("foreign issue"); + assert!(matches!( + Durability::open(manager(root.path()), workspace, input).await, + Err(DurabilityError::Binding("issue")) + )); + } + + #[tokio::test] + async fn native_manifests_remain_readable_and_are_not_overwritten() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let native = ConversationManifest::new( + &workspace, + "native-session", + "http://localhost", + workspace.metadata_dir(), + "native-v1", + ); + let raw = serde_json::to_value(&native).expect("native serialization"); + assert!(raw.get("acp").is_none()); + let decoded: ConversationManifest = serde_json::from_value(raw).expect("legacy manifest"); + assert!(decoded.acp.is_none()); + manager(root.path()) + .write_conversation_manifest(&workspace, &native) + .await + .expect("native write"); + assert!(matches!( + Durability::open( + manager(root.path()), + workspace.clone(), + identity(&workspace) + ) + .await, + Err(DurabilityError::NativeManifest) + )); + assert_eq!( + manager(root.path()) + .load_conversation_manifest(&workspace) + .await + .expect("read") + .expect("native"), + native + ); + } + + #[tokio::test] + async fn invalid_or_oversized_metadata_never_replaces_the_durable_record() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let mut owner = Durability::open( + manager(root.path()), + workspace.clone(), + identity(&workspace), + ) + .await + .expect("owner"); + let before = std::fs::read(workspace.conversation_manifest_path()).expect("reservation"); + assert!(matches!( + owner + .ready("bad\nsession".into(), Value::Null, AcpRecovery::Fresh, None) + .await, + Err(DurabilityError::InvalidMetadata) + )); + assert!(matches!( + owner + .ready( + "session".into(), + json!("x".repeat(MAX_MANIFEST_BYTES)), + AcpRecovery::Fresh, + None + ) + .await, + Err(DurabilityError::InvalidMetadata) + )); + assert_eq!( + std::fs::read(workspace.conversation_manifest_path()).expect("reservation remains"), + before + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn rejects_symlinked_lock_and_manifest_without_touching_the_target() { + for filename in ["acp-owner.lock", "conversation.json"] { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let outside = root.path().join("outside"); + std::fs::write(&outside, "unchanged").expect("outside"); + std::os::unix::fs::symlink(&outside, workspace.metadata_dir().join(filename)) + .expect("symlink"); + assert!(matches!( + Durability::open( + manager(root.path()), + workspace.clone(), + identity(&workspace) + ) + .await, + Err(DurabilityError::Workspace( + WorkspaceError::ManagedPathSymlink { .. } + )) + )); + assert_eq!( + std::fs::read_to_string(outside).expect("outside remains"), + "unchanged" + ); + } + } +} diff --git a/crates/opensymphony-acp/src/host.rs b/crates/opensymphony-acp/src/host.rs new file mode 100644 index 000000000..0408c9268 --- /dev/null +++ b/crates/opensymphony-acp/src/host.rs @@ -0,0 +1,929 @@ +//! Bounded runtime ownership. Scheduler decisions remain outside this actor. +use super::{ + AcpProfile, ClientError, ClientLimits, LaunchContext, SharedCapture, SourceFrame, TurnReport, + durable::{Durability, DurabilityError, profile_fingerprint}, +}; +use crate::opensymphony_workspace::{ + AcpRecovery, AcpSessionIdentity, AcpSessionState, AcpSessionStatus, WorkspaceHandle, + WorkspaceManager, +}; +use agent_client_protocol::{ + Agent, ConnectionTo, UntypedMessage, + schema::v1::{ + CancelNotification, ContentBlock, InitializeResponse, PromptRequest, SessionId, TextContent, + }, +}; +use futures_util::{StreamExt, stream::FuturesUnordered}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, VecDeque}, + future::Future, + pin::Pin, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone)] +pub struct RetentionPolicy { + pub max_sessions: usize, + pub idle_timeout: Duration, + pub attachment_ttl: Duration, + pub max_attachments: usize, +} +impl Default for RetentionPolicy { + fn default() -> Self { + Self { + max_sessions: 8, + idle_timeout: Duration::from_secs(900), + attachment_ttl: Duration::from_secs(60), + max_attachments: 16, + } + } +} + +/// Host callers supply the scheduler's verified workspace and current credential/grant revision. +pub struct SessionLaunch { + pub manager: WorkspaceManager, + pub workspace: WorkspaceHandle, + pub identity: AcpSessionIdentity, + pub profile: AcpProfile, + pub context: LaunchContext, + pub limits: ClientLimits, + pub require_persistence: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)] +pub enum HostError { + #[error("ACP owner is unavailable; inspect persisted state before recovery")] + Unavailable, + #[error("ACP session identity or connection generation does not match")] + IdentityMismatch, + #[error("ACP session has active work or attachment leases")] + Busy, + #[error("ACP host or subscriber resource limit reached")] + ResourceLimit, + #[error("ACP durable session reservation or checkpoint failed")] + Persistence, + #[error("ACP session has a live owner or prior process; attach through that host")] + AlreadyOwned, + #[error( + "ACP prior prompt delivery is uncertain; reconcile execution risk before another attempt" + )] + UncertainSubmission, + #[error("ACP host was lost during process launch; reconcile process ownership before retrying")] + UncertainLaunch, + #[error("ACP cannot replace a native conversation manifest")] + NativeManifest, + #[error("ACP session cannot restore context with negotiated capabilities")] + PersistenceUnsupported, + #[error("ACP session launch or setup failed: {0}")] + Client(String), +} + +impl From for HostError { + fn from(error: DurabilityError) -> Self { + match error { + DurabilityError::Owned => Self::AlreadyOwned, + DurabilityError::Uncertain => Self::UncertainSubmission, + DurabilityError::ProcessUncertain => Self::UncertainLaunch, + DurabilityError::NativeManifest => Self::NativeManifest, + DurabilityError::Binding(_) => Self::IdentityMismatch, + _ => Self::Persistence, + } + } +} + +/// Read recorded evidence without claiming that agent context or a live owner was restored. +pub async fn inspect_recorded_session( + manager: &WorkspaceManager, + workspace: &WorkspaceHandle, +) -> Result { + let manifest = manager + .load_conversation_manifest(workspace) + .await + .map_err(|_| HostError::Persistence)? + .ok_or(HostError::Unavailable)?; + if manifest.issue_id != workspace.issue_id() || manifest.identifier != workspace.identifier() { + return Err(HostError::IdentityMismatch); + } + let mut state = manifest.acp.ok_or(HostError::NativeManifest)?; + if state.identity.workspace_path != workspace.workspace_path() { + return Err(HostError::IdentityMismatch); + } + state.recovery = AcpRecovery::TranscriptOnly; + Ok(SessionSnapshot { + state, + live: false, + attachment_count: 0, + retirement_eligible: false, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSnapshot { + pub state: AcpSessionState, + pub live: bool, + pub attachment_count: usize, + pub retirement_eligible: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SessionEvent { + Source { + generation: u64, + run_id: String, + replay: bool, + frame: SourceFrame, + }, + State { + snapshot: Box, + }, + Gap { + generation: u64, + sequence: u64, + }, + Ended { + generation: u64, + cleanup_ready: bool, + detail: Option, + }, +} + +#[derive(Clone)] +pub(super) struct EventPublisher { + tx: broadcast::Sender, + binding: Arc>, + replay_request: Arc>>, + history: Arc>, +} +impl EventPublisher { + pub(super) fn end_replay(&self) { + self.binding.lock().unwrap_or_else(|e| e.into_inner()).2 = false; + } + pub(super) fn publish(&self, frame: SourceFrame) { + if frame.direction == "outgoing" && frame.payload["method"] == "session/load" { + *self + .replay_request + .lock() + .unwrap_or_else(|e| e.into_inner()) = frame.payload.get("id").cloned(); + } + let ends_replay = frame.direction == "incoming" + && frame.payload.get("method").is_none() + && frame.payload.get("id").is_some_and(|id| { + self.replay_request + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + == Some(id) + }); + let (generation, run_id, replay) = self + .binding + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let event = SessionEvent::Source { + generation, + run_id, + replay, + frame, + }; + let admitted = self + .history + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(event.clone()); + let event = if admitted { + event + } else { + let SessionEvent::Source { frame, .. } = event else { + unreachable!() + }; + SessionEvent::Gap { + generation, + sequence: frame.sequence, + } + }; + let _ = self.tx.send(event); + if ends_replay { + self.end_replay(); + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceHistory { + pub events: Vec, + pub truncated: bool, +} +struct EventHistory { + events: VecDeque<(SessionEvent, usize)>, + bytes: usize, + max_bytes: usize, + max_frames: usize, + truncated: bool, +} +impl EventHistory { + fn retain(&mut self, event: SessionEvent) -> bool { + let bytes = serde_json::to_vec(&event).map_or(usize::MAX, |v| v.len()); + if bytes > self.max_bytes { + self.truncated = true; + return false; + } + while self.events.len() >= self.max_frames + || self.bytes.saturating_add(bytes) > self.max_bytes + { + let Some((_, removed)) = self.events.pop_front() else { + break; + }; + self.bytes -= removed; + self.truncated = true; + } + self.bytes += bytes; + self.events.push_back((event, bytes)); + true + } +} + +#[derive(Clone)] +pub struct SessionHandle { + pub owner_id: String, + pub generation: u64, + commands: mpsc::Sender, + events: broadcast::Sender, + history: Arc>, + persistence_supported: bool, +} +impl std::fmt::Debug for SessionHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionHandle") + .field("generation", &self.generation) + .finish_non_exhaustive() + } +} +impl SessionHandle { + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + /// Subscribe first, then read history and deduplicate source sequence numbers. + pub fn source_history(&self) -> SourceHistory { + let history = self.history.lock().unwrap_or_else(|e| e.into_inner()); + SourceHistory { + events: history + .events + .iter() + .map(|(event, _)| event.clone()) + .collect(), + truncated: history.truncated, + } + } + pub async fn inspect(&self) -> Result { + match self.control(SessionControl::Inspect).await? { + ControlResult::Snapshot(s) => Ok(*s), + _ => Err(HostError::Unavailable), + } + } + /// Observation pins the live process but grants no prompt/write authority. + pub async fn control(&self, action: SessionControl) -> Result { + let (reply, receive) = oneshot::channel(); + self.commands + .try_send(Command::Control { + generation: self.generation, + action, + reply, + }) + .map_err(channel_error)?; + receive.await.map_err(|_| HostError::Unavailable)? + } + pub async fn prompt( + &self, + run_id: String, + attempt: u32, + prompt: String, + cancellation: CancellationToken, + ) -> Result { + let (reply, receive) = oneshot::channel(); + self.commands + .try_send(Command::Prompt { + generation: self.generation, + run_id, + attempt, + prompt, + cancellation, + reply, + }) + .map_err(channel_error)?; + receive.await.map_err(|_| HostError::Unavailable)? + } +} +fn channel_error(error: mpsc::error::TrySendError) -> HostError { + match error { + mpsc::error::TrySendError::Closed(_) => HostError::Unavailable, + mpsc::error::TrySendError::Full(_) => HostError::ResourceLimit, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum SessionControl { + Inspect, + Attach, + Renew { lease_id: String }, + Release { lease_id: String }, + Retire, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ControlResult { + Snapshot(Box), + Lease { lease_id: String, ttl_ms: u64 }, + Released, +} + +enum Command { + Control { + generation: u64, + action: SessionControl, + reply: oneshot::Sender>, + }, + Prompt { + generation: u64, + run_id: String, + attempt: u32, + prompt: String, + cancellation: CancellationToken, + reply: oneshot::Sender>, + }, +} +enum HostCommand { + Open { + launch: Box, + reply: oneshot::Sender>, + }, + Lookup { + owner_id: String, + generation: u64, + reply: oneshot::Sender>, + }, +} +type LaunchCompletion = ( + String, + oneshot::Sender>, + Result<(AcpSessionIdentity, SessionHandle), HostError>, +); + +#[derive(Clone, Debug)] +pub struct SessionHost { + commands: mpsc::Sender, +} +impl SessionHost { + pub fn new(policy: RetentionPolicy) -> Result { + if !(1..=128).contains(&policy.max_sessions) + || !(1..=128).contains(&policy.max_attachments) + || policy.idle_timeout.is_zero() + || policy.attachment_ttl.is_zero() + || policy.idle_timeout > Duration::from_secs(86400) + || policy.attachment_ttl > Duration::from_secs(3600) + { + return Err(HostError::ResourceLimit); + } + let (commands, mut receive) = mpsc::channel(32); + tokio::spawn(async move { + let mut sessions: BTreeMap = + BTreeMap::new(); + let mut starting = FuturesUnordered::new(); + let mut pending = std::collections::BTreeSet::new(); + loop { + let command = tokio::select! { + completed = starting.next(), if !starting.is_empty() => { + let (key, reply, result): LaunchCompletion = completed.expect("pending launch"); + pending.remove(&key); + if let Ok((identity, handle)) = &result { sessions.insert(key, (identity.clone(), handle.clone())); } + let _ = reply.send(result.map(|(_, handle)| handle)); + continue; + } + command = receive.recv() => match command { Some(command) => command, None => break }, + }; + sessions.retain(|_, (_, handle)| !handle.commands.is_closed()); + match command { + HostCommand::Lookup { + owner_id, + generation, + reply, + } => { + let result = sessions + .values() + .find(|(_, h)| h.owner_id == owner_id) + .ok_or(HostError::Unavailable) + .and_then(|(_, h)| { + if h.generation == generation { + Ok(h.clone()) + } else { + Err(HostError::IdentityMismatch) + } + }); + let _ = reply.send(result); + } + HostCommand::Open { mut launch, reply } => { + let key = launch.workspace.issue_id().to_owned(); + let fingerprint = profile_fingerprint(&launch.profile); + let Ok(fingerprint) = fingerprint else { + let _ = reply.send(Err(HostError::Persistence)); + continue; + }; + launch.identity.profile_fingerprint = fingerprint; + if super::validate_launch(&launch.profile, &launch.context, &launch.limits) + .is_err() + || launch.context.issue_workspace != launch.identity.workspace_path + || launch.context.workspace_key != launch.workspace.workspace_key() + { + let _ = reply.send(Err(HostError::IdentityMismatch)); + continue; + } + if pending.contains(&key) { + let _ = reply.send(Err(HostError::Busy)); + continue; + } + if let Some((identity, handle)) = sessions.get(&key) { + if launch.require_persistence && !handle.persistence_supported { + let _ = reply.send(Err(HostError::PersistenceUnsupported)); + continue; + } + let mut expected = launch.identity.clone(); + expected.generation = identity.generation; + expected.run_id.clone_from(&identity.run_id); + expected.attempt = identity.attempt; + let result = if expected == *identity + && (launch.identity.generation == 0 + || launch.identity.generation == handle.generation) + { + Ok(handle.clone()) + } else { + Err(HostError::IdentityMismatch) + }; + let _ = reply.send(result); + continue; + } + if sessions.len() + pending.len() >= policy.max_sessions { + let _ = reply.send(Err(HostError::ResourceLimit)); + continue; + } + pending.insert(key.clone()); + let policy = policy.clone(); + starting.push(async move { (key, reply, start(*launch, policy).await) }); + } + } + } + }); + Ok(Self { commands }) + } + pub async fn open(&self, launch: SessionLaunch) -> Result { + let (reply, receive) = oneshot::channel(); + self.commands + .try_send(HostCommand::Open { + launch: Box::new(launch), + reply, + }) + .map_err(channel_error)?; + receive.await.map_err(|_| HostError::Unavailable)? + } + pub async fn lookup( + &self, + owner_id: String, + generation: u64, + ) -> Result { + let (reply, receive) = oneshot::channel(); + self.commands + .try_send(HostCommand::Lookup { + owner_id, + generation, + reply, + }) + .map_err(channel_error)?; + receive.await.map_err(|_| HostError::Unavailable)? + } +} + +async fn start( + launch: SessionLaunch, + policy: RetentionPolicy, +) -> Result<(AcpSessionIdentity, SessionHandle), HostError> { + super::validate_launch(&launch.profile, &launch.context, &launch.limits) + .map_err(|e| HostError::Client(e.to_string()))?; + if launch.context.issue_workspace != launch.workspace.workspace_path() + || launch.context.workspace_key != launch.workspace.workspace_key() + || launch.identity.workspace_path != launch.workspace.workspace_path() + { + return Err(HostError::IdentityMismatch); + } + let durable = Durability::open(launch.manager, launch.workspace, launch.identity) + .await + .map_err(HostError::from)?; + let identity = durable.state().identity.clone(); + let (commands, receive) = mpsc::channel(16); + let (events, _) = broadcast::channel( + launch + .limits + .queued_frames + .min((64 * 1024 * 1024 / launch.limits.queued_bytes).max(1)), + ); + let history = Arc::new(Mutex::new(EventHistory { + events: VecDeque::new(), + bytes: 0, + max_bytes: launch.limits.queued_bytes, + max_frames: launch.limits.queued_frames, + truncated: false, + })); + let mut handle = SessionHandle { + owner_id: durable.state().owner_id.clone(), + generation: identity.generation, + commands, + events: events.clone(), + history: history.clone(), + persistence_supported: false, + }; + let (ready, ready_receive) = oneshot::channel(); + let publisher = EventPublisher { + tx: events, + binding: Arc::new(Mutex::new(( + identity.generation, + identity.run_id.clone(), + false, + ))), + replay_request: Arc::new(Mutex::new(None)), + history, + }; + let mut driver = SessionDriver { + durable, + receive, + publisher, + policy, + require_persistence: launch.require_persistence, + ready: Some(ready), + attachments: BTreeMap::new(), + idle_since: tokio::time::Instant::now(), + retire_reply: None, + reset_reason: None, + }; + tokio::spawn(async move { + let result = super::run_connection( + &launch.profile, + launch.context, + String::new(), + CancellationToken::new(), + None, + launch.limits, + Some(&mut driver), + ) + .await; + let error = match &result { + Ok(result) => result.outcome.as_ref().err().map(ToString::to_string), + Err(error) => Some(error.to_string()), + }; + // A failed connection cannot establish remote quiescence. A submitted marker + // survives even if the outcome checkpoint fails, and prevents another prompt. + let checkpointed = if result + .as_ref() + .is_ok_and(|r| r.process_reaped && r.process_tree_signal_error.is_none()) + || matches!( + result, + Err(ClientError::InvalidConfiguration(_) + | ClientError::InvalidWorkspace + | ClientError::Launch(_) + | ClientError::CancelledBeforePrompt) + ) { + driver.durable.stopped().await.is_ok() + } else { + false + }; + if driver.durable.state().status == AcpSessionStatus::Submitted { + let _ = driver.durable.uncertain().await; + } + if let Some(ready) = driver.ready.take() { + let _ = ready.send(Err(HostError::Client( + error + .clone() + .unwrap_or_else(|| "owner exited during setup".into()), + ))); + } + let cleanup_ready = error.is_none() + && checkpointed + && matches!( + driver.durable.state().status, + AcpSessionStatus::Ready | AcpSessionStatus::Finished + ) + && result + .as_ref() + .is_ok_and(|r| r.process_reaped && r.process_tree_signal_error.is_none()); + let mut ended_snapshot = driver.snapshot(false); + ended_snapshot.live = false; + ended_snapshot.retirement_eligible = false; + ended_snapshot.state.recovery = AcpRecovery::TranscriptOnly; + let _ = driver.publisher.tx.send(SessionEvent::State { + snapshot: Box::new(ended_snapshot), + }); + let reply = driver.retire_reply.take(); + let publisher = driver.publisher.clone(); + let generation = driver.durable.state().identity.generation; + drop(driver); // Release the owner lock and close commands before acknowledging retirement. + let _ = publisher.tx.send(SessionEvent::Ended { + generation, + cleanup_ready, + detail: error, + }); + if let Some(reply) = reply { + let _ = reply.send(if cleanup_ready { + Ok(ControlResult::Released) + } else { + Err(HostError::Unavailable) + }); + } + }); + handle.persistence_supported = ready_receive.await.map_err(|_| HostError::Unavailable)??; + Ok((identity, handle)) +} + +type PromptFuture = Pin> + Send>>; +struct ActivePrompt { + result: PromptFuture, + reply: oneshot::Sender>, + cancellation: CancellationToken, +} + +pub(super) struct SessionDriver { + durable: Durability, + receive: mpsc::Receiver, + pub(super) publisher: EventPublisher, + policy: RetentionPolicy, + require_persistence: bool, + ready: Option>>, + attachments: BTreeMap, + idle_since: tokio::time::Instant, + retire_reply: Option>>, + reset_reason: Option, +} +impl SessionDriver { + pub(super) async fn launched(&mut self, pid: Option) -> Result<(), ClientError> { + self.durable + .launched(pid.ok_or(ClientError::Teardown)?) + .await + .map_err(|_| ClientError::Setup("process ownership checkpoint failed".into())) + } + pub(super) fn reset_missing_session(&mut self, error: &agent_client_protocol::Error) -> bool { + if self.require_persistence + || error.code != agent_client_protocol::schema::v1::ErrorCode::ResourceNotFound + { + return false; + } + self.durable.state_mut().recovery = AcpRecovery::Fresh; + self.reset_reason = Some( + "agent no longer has persisted context; fresh full-context prompt required".into(), + ); + self.publisher.end_replay(); + true + } + pub(super) fn restoration( + &mut self, + initialization: &InitializeResponse, + ) -> Result, HostError> { + let capabilities = &initialization.agent_capabilities; + let has_resume = capabilities.session_capabilities.resume.is_some(); + let has_load = capabilities.load_session; + if self.require_persistence && !has_resume && !has_load { + return Err(HostError::PersistenceUnsupported); + } + let Some(id) = self.durable.state().session_id.clone() else { + return Ok(None); + }; + let recovery = if has_resume { + AcpRecovery::RestoredResume + } else if has_load { + AcpRecovery::RestoredLoad + } else { + AcpRecovery::Fresh + }; + self.durable.state_mut().recovery = recovery; + let replay = recovery == AcpRecovery::RestoredLoad; + self.publisher + .binding + .lock() + .unwrap_or_else(|e| e.into_inner()) + .2 = replay; + if has_resume || has_load { + Ok(Some((id, replay))) + } else { + Ok(None) + } + } + fn snapshot(&self, active: bool) -> SessionSnapshot { + let mut state = self.durable.state().clone(); + if state.status == AcpSessionStatus::Finished { + state.recovery = AcpRecovery::LiveAttach; + } + SessionSnapshot { + state, + live: true, + attachment_count: self.attachments.len(), + retirement_eligible: !active + && self.attachments.is_empty() + && matches!( + self.durable.state().status, + AcpSessionStatus::Ready | AcpSessionStatus::Finished + ), + } + } + fn publish(&self, active: bool) { + let _ = self.publisher.tx.send(SessionEvent::State { + snapshot: Box::new(self.snapshot(active)), + }); + } + pub(super) async fn drive( + &mut self, + connection: ConnectionTo, + initialization: InitializeResponse, + session_id: SessionId, + capture: &SharedCapture, + limits: &ClientLimits, + ) -> Result { + let mut metadata = serde_json::to_value(&initialization) + .map_err(|_| ClientError::Setup("invalid negotiation metadata".into()))?; + { + let capture = capture.lock().unwrap_or_else(|e| e.into_inner()); + if session_id.0.len() > 8192 + || session_id.0.is_empty() + || capture.secrets.contains_secret(&session_id.0) + { + return Err(ClientError::Setup( + "invalid or credential-bearing session identity".into(), + )); + } + capture.redact(&mut metadata, false); + } + let recovery = self.durable.state().recovery; + let reset_reason = if self.reset_reason.is_some() { + self.reset_reason.take() + } else if self.durable.state().session_id.is_some() && recovery == AcpRecovery::Fresh { + Some("agent context unavailable: persistence unsupported; fresh full-context prompt required".into()) + } else { + None + }; + self.durable + .ready(session_id.0.to_string(), metadata, recovery, reset_reason) + .await + .map_err(|_| ClientError::Setup("durable session checkpoint failed".into()))?; + self.publisher + .binding + .lock() + .unwrap_or_else(|e| e.into_inner()) + .2 = false; + self.idle_since = tokio::time::Instant::now(); + if let Some(ready) = self.ready.take() { + let _ = ready.send(Ok(initialization.agent_capabilities.load_session + || initialization + .agent_capabilities + .session_capabilities + .resume + .is_some())); + } + self.publish(false); + let mut active: Option = None; + let mut tick = tokio::time::interval(Duration::from_millis(50)); + let mut commands_closed = false; + loop { + tokio::select! { + result = async { active.as_mut().expect("guarded active prompt").result.as_mut().await }, if active.is_some() => { + let pending = active.take().expect("active prompt"); + let report = match result { + Ok(report) => report, + Err(error) => { let _ = self.durable.uncertain().await; let _ = pending.reply.send(Err(HostError::Client(error.to_string()))); return Err(error); } + }; + if self.durable.finished(report.stop_reason.clone()).await.is_err() { + let _ = pending.reply.send(Err(HostError::Persistence)); + return Err(ClientError::Setup("terminal checkpoint failed; submission remains fenced".into())); + } + let _ = pending.reply.send(Ok(report)); + self.idle_since = tokio::time::Instant::now(); + self.publish(false); + } + command = self.receive.recv(), if !commands_closed => { + let Some(command) = command else { + commands_closed = true; + if let Some(active) = &active { active.cancellation.cancel(); } + continue; + }; + match command { + Command::Prompt { generation, run_id, attempt, prompt, cancellation, reply } => { + if generation != self.durable.state().identity.generation { let _ = reply.send(Err(HostError::IdentityMismatch)); continue; } + if active.is_some() { let _ = reply.send(Err(HostError::Busy)); continue; } + if prompt.len() > limits.frame_bytes / 6 || run_id.is_empty() || run_id.len() > 1024 { let _ = reply.send(Err(HostError::ResourceLimit)); continue; } + if cancellation.is_cancelled() { let _ = reply.send(Err(HostError::Client(ClientError::CancelledBeforePrompt.to_string()))); continue; } + if self.durable.submitted(run_id.clone(), attempt).await.is_err() { let _ = reply.send(Err(HostError::Persistence)); return Err(ClientError::Setup("submission checkpoint failed".into())); } + self.publisher.binding.lock().unwrap_or_else(|e| e.into_inner()).1 = run_id; + let forced = CancellationToken::new(); + active = Some(ActivePrompt { result: Box::pin(prompt_rpc(connection.clone(), initialization.clone(), session_id.clone(), prompt, cancellation, forced.clone(), capture.clone(), limits.clone())), reply, cancellation: forced }); + self.publish(true); + } + Command::Control { generation, action, reply } => { + if generation != self.durable.state().identity.generation { let _ = reply.send(Err(HostError::IdentityMismatch)); continue; } + self.expire_leases(); + let result = match action { + SessionControl::Inspect => Ok(ControlResult::Snapshot(Box::new(self.snapshot(active.is_some())))), + SessionControl::Attach => { + if self.attachments.len() >= self.policy.max_attachments { Err(HostError::ResourceLimit) } else { + let lease_id = uuid::Uuid::new_v4().to_string(); + self.attachments.insert(lease_id.clone(), tokio::time::Instant::now() + self.policy.attachment_ttl); + Ok(ControlResult::Lease { lease_id, ttl_ms: self.policy.attachment_ttl.as_millis() as u64 }) + } + } + SessionControl::Renew { lease_id } => match self.attachments.get_mut(&lease_id) { + Some(expiry) => { *expiry = tokio::time::Instant::now() + self.policy.attachment_ttl; Ok(ControlResult::Lease { lease_id, ttl_ms: self.policy.attachment_ttl.as_millis() as u64 }) } + None => Err(HostError::IdentityMismatch), + }, + SessionControl::Release { lease_id } => if self.attachments.remove(&lease_id).is_some() { self.idle_since = tokio::time::Instant::now(); Ok(ControlResult::Released) } else { Err(HostError::IdentityMismatch) }, + SessionControl::Retire => { + if active.is_some() || !self.attachments.is_empty() { Err(HostError::Busy) } else { self.retire_reply = Some(reply); break; } + } + }; + let _ = reply.send(result); + } + } + } + _ = tick.tick() => { + self.expire_leases(); + if active.is_none() && self.attachments.is_empty() && (commands_closed || self.idle_since.elapsed() >= self.policy.idle_timeout) { break; } + } + } + } + Ok(TurnReport { + stop_reason: "session_retired".into(), + cancellation_requested: false, + cancellation_acknowledged: false, + session_id: session_id.0.to_string(), + initialization, + }) + } + fn expire_leases(&mut self) { + self.attachments + .retain(|_, expiry| *expiry > tokio::time::Instant::now()); + } +} + +#[allow(clippy::too_many_arguments)] +async fn prompt_rpc( + connection: ConnectionTo, + initialization: InitializeResponse, + session_id: SessionId, + prompt: String, + cancellation: CancellationToken, + forced: CancellationToken, + capture: SharedCapture, + limits: ClientLimits, +) -> Result { + let request = UntypedMessage::new( + "session/prompt", + PromptRequest::new( + session_id.clone(), + vec![ContentBlock::Text(TextContent::new(prompt))], + ), + ) + .map_err(|_| ClientError::Protocol { submitted: false })?; + let response = connection.send_request(request).block_task(); + tokio::pin!(response); + let mut cancellation_requested = false; + let result = tokio::select! { + result = &mut response => result, + _ = async { tokio::select! { _ = cancellation.cancelled() => {}, _ = forced.cancelled() => {} } } => { + cancellation_requested = true; + connection.send_notification(CancelNotification::new(session_id.clone())).map_err(|_| ClientError::Protocol { submitted: true })?; + tokio::time::timeout(limits.cancel_timeout, &mut response).await.map_err(|_| ClientError::CancelTimeout)? + }, + _ = tokio::time::sleep(limits.prompt_timeout) => return Err(ClientError::PromptTimeout), + }.map_err(|error| super::rpc_failure("session/prompt", &error, &capture, true).unwrap_or(ClientError::Protocol { submitted: true }))?; + let stop_reason = result + .get("stopReason") + .and_then(serde_json::Value::as_str) + .filter(|reason| reason.len() <= 1024) + .ok_or(ClientError::Protocol { submitted: true })? + .to_owned(); + // Unknown reasons do not establish the tested stop contract. + if ![ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled", + ] + .contains(&stop_reason.as_str()) + { + return Err(ClientError::Protocol { submitted: true }); + } + Ok(TurnReport { + cancellation_acknowledged: cancellation_requested && stop_reason == "cancelled", + cancellation_requested, + stop_reason, + session_id: session_id.0.to_string(), + initialization, + }) +} diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs index c3e598f40..fa2681e13 100644 --- a/crates/opensymphony-acp/src/lib.rs +++ b/crates/opensymphony-acp/src/lib.rs @@ -1,6 +1,6 @@ //! Executable ACP v1 stdio client. Durable ownership and worker routing live in the host. //! -//! `run_turn` owns one process for one complete turn; it never resumes or retries a prompt. +//! `SessionHost` retains process ownership; `run_turn` remains a one-turn compatibility API. //! A protocol stop reason is distinct from proof that remote delegated work has stopped. use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, @@ -20,8 +20,9 @@ use agent_client_protocol::{ ProtocolVersion, v1::{ AuthMethod, AuthenticateRequest, CancelNotification, ContentBlock, InitializeRequest, - InitializeResponse, NewSessionRequest, PromptRequest, RequestPermissionOutcome, - RequestPermissionRequest, RequestPermissionResponse, TextContent, + InitializeResponse, LoadSessionRequest, NewSessionRequest, PromptRequest, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + ResumeSessionRequest, TextContent, }, }, }; @@ -41,6 +42,10 @@ use tokio_util::{ }; use tracing::instrument::WithSubscriber; +mod durable; +mod host; +pub use host::*; + #[cfg(windows)] mod windows_process; @@ -324,6 +329,7 @@ struct Capture { bytes: usize, max_bytes: usize, secrets: SecretRedactor, + publisher: Option, } impl Capture { @@ -367,6 +373,16 @@ impl Capture { } fn record(&mut self, direction: &str, mut payload: Value) { self.sequence += 1; + if let Some(publisher) = &self.publisher { + let mut source = payload.clone(); + self.redact(&mut source, false); + publisher.publish(SourceFrame { + sequence: self.sequence, + direction: direction.into(), + observed_at: chrono::Utc::now(), + payload: source, + }); + } if self.frames.len() == self.max || self.bytes == self.max_bytes { self.truncated = true; return; @@ -579,6 +595,27 @@ pub async fn run_turn( cancellation: CancellationToken, updates: Option>, limits: ClientLimits, +) -> Result { + run_connection( + profile, + context, + prompt, + cancellation, + updates, + limits, + None, + ) + .await +} + +async fn run_connection( + profile: &AcpProfile, + context: LaunchContext, + prompt: String, + cancellation: CancellationToken, + updates: Option>, + limits: ClientLimits, + mut driver: Option<&mut SessionDriver>, ) -> Result { let PreparedLaunch { cwd, @@ -596,6 +633,7 @@ pub async fn run_turn( bytes: 0, max_bytes: limits.evidence_bytes, secrets, + publisher: driver.as_ref().map(|driver| driver.publisher.clone()), })); let mut command = Command::new(&profile.command); command @@ -618,6 +656,9 @@ pub async fn run_turn( let process_id = child.id(); #[cfg(unix)] let mut group_guard = crate::opensymphony_workspace::ProcessGroupGuard::new(process_id); + if let Some(driver) = driver.as_deref_mut() { + driver.launched(child.id()).await?; + } let stdin = child.stdin.take().ok_or(ClientError::Teardown)?; let stdout = child.stdout.take().ok_or(ClientError::Teardown)?; let stderr = child.stderr.take().ok_or(ClientError::Teardown)?; @@ -841,25 +882,61 @@ pub async fn run_turn( phase_error = rpc_failure("authenticate", error, &capture_state, false); })?; } - let (session_tx, session_rx) = oneshot::channel(); - let active_session = active_session.clone(); - // Register before yielding. The ordered callback binds ownership before - // the SDK releases any adjacent session/update or permission request. - connection.send_request(NewSessionRequest::new(cwd)) - .on_receiving_result(async move |result| { - if let Ok(session) = &result { - *active_session.lock().unwrap_or_else(|e| e.into_inner()) = - Some(session.session_id.0.to_string()); + let restoration = match driver.as_deref_mut() { + Some(driver) => driver.restoration(&initialization).map_err(|error| { + phase_error = Some(ClientError::Setup(error.to_string())); + agent_client_protocol::Error::internal_error() + })?, + None => None, + }; + let restored_id = if let Some((session_id, replay)) = restoration { + // Bind before loading: replay notifications can precede the load response. + *active_session.lock().unwrap_or_else(|e| e.into_inner()) = Some(session_id.clone()); + let restored = if replay { + let (loaded_tx, loaded_rx) = oneshot::channel(); + let publisher = driver.as_ref().map(|driver| driver.publisher.clone()); + connection.send_request(LoadSessionRequest::new(session_id.clone(), cwd.clone())) + .on_receiving_result(async move |result| { + if let Some(publisher) = publisher { + publisher.end_replay(); + } + let _ = loaded_tx.send(result); + Ok(()) + })?; + loaded_rx.await.map_err(|_| agent_client_protocol::Error::internal_error())?.map(|_| ()) + } else { + connection.send_request(ResumeSessionRequest::new(session_id.clone(), cwd.clone())) + .block_task().await.map(|_| ()) + }; + match restored { + Ok(()) => Some(session_id), + Err(error) if driver.as_deref_mut().is_some_and(|driver| driver.reset_missing_session(&error)) => None, + Err(error) => { + phase_error = rpc_failure(if replay { "session/load" } else { "session/resume" }, &error, &capture_state, false); + return Err(error); } - let _ = session_tx.send(result); - Ok(()) - })?; - let session = session_rx.await - .map_err(|_| agent_client_protocol::Error::internal_error())? - .inspect_err(|error| { - phase_error = rpc_failure("session/new", error, &capture_state, false); - })?; - Ok((initialization, session.session_id)) + } + } else { None }; + let session_id = if let Some(session_id) = restored_id { session_id.into() } else { + let (session_tx, session_rx) = oneshot::channel(); + let active_session = active_session.clone(); + // Bind in ordered response dispatch before any adjacent session update. + connection.send_request(NewSessionRequest::new(cwd.clone())) + .on_receiving_result(async move |result| { + if let Ok(session) = &result { + *active_session.lock().unwrap_or_else(|e| e.into_inner()) = + Some(session.session_id.0.to_string()); + } + let _ = session_tx.send(result); + Ok(()) + })?; + session_rx.await + .map_err(|_| agent_client_protocol::Error::internal_error())? + .inspect_err(|error| { + phase_error = rpc_failure("session/new", error, &capture_state, false); + })?.session_id + }; + Ok((initialization, session_id)) }; let setup_result = tokio::select! { _ = cancellation.cancelled() => { @@ -875,6 +952,13 @@ pub async fn run_turn( return Err(agent_client_protocol::Error::internal_error()); } }; + if let Some(driver) = driver.as_deref_mut() { + return driver.drive(connection, initialization, session_id, &capture_state, &limits).await + .map_err(|error| { + phase_error = Some(error); + agent_client_protocol::Error::internal_error() + }); + } if cancellation.is_cancelled() { phase_error = Some(ClientError::CancelledBeforePrompt); return Err(agent_client_protocol::Error::internal_error()); @@ -1137,6 +1221,7 @@ mod tests { bytes: 0, max_bytes: budget, secrets: SecretRedactor::new(Vec::new()).expect("empty matcher"), + publisher: None, }; capture.retain_frame(frame.clone()); capture.retain_frame(frame.clone()); diff --git a/crates/opensymphony-acp/src/windows_process.rs b/crates/opensymphony-acp/src/windows_process.rs index 5eaf4a69d..a3dba3814 100644 --- a/crates/opensymphony-acp/src/windows_process.rs +++ b/crates/opensymphony-acp/src/windows_process.rs @@ -25,7 +25,6 @@ impl WindowsChild { }) } - #[cfg(test)] pub(super) fn id(&self) -> Option { self.inner.id() } diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 28678929c..a174d1781 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -5865,6 +5865,7 @@ impl RuntimeWorkerBackend { target_commit: checkout.target_commit.clone(), instruction: checkout.instruction.clone(), harness: route.harness_kind.clone(), + acp_session: None, model_profile: route .model_profile .clone() diff --git a/crates/opensymphony-control/src/acp.rs b/crates/opensymphony-control/src/acp.rs new file mode 100644 index 000000000..9e88a081a --- /dev/null +++ b/crates/opensymphony-control/src/acp.rs @@ -0,0 +1,140 @@ +//! Private host-session observation seam for a separate debug process. +//! Prompt authority and scheduler holds are deliberately owned by the later control-handoff layer. +use crate::opensymphony_acp::{ + ControlResult, HostError, SessionControl, SessionEvent, SessionHost, +}; +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::sse::{Event, KeepAlive, Sse}, + routing::{get, post}, +}; +use serde::Deserialize; +use std::{convert::Infallible, sync::Arc, time::Duration}; + +#[derive(Clone)] +struct HostState { + host: SessionHost, + bearer: Arc, +} +#[derive(Deserialize)] +struct Binding { + generation: u64, +} +#[derive(Deserialize)] +struct Command { + generation: u64, + #[serde(flatten)] + action: SessionControl, +} + +pub(super) fn router(host: SessionHost, bearer: String) -> Result { + if bearer.len() < 32 || bearer.chars().any(char::is_whitespace) { + return Err("ACP control bearer must contain at least 32 non-whitespace characters"); + } + Ok(Router::new() + .route("/api/v1/acp/{owner_id}", post(command)) + .route("/api/v1/acp/{owner_id}/events", get(events)) + .layer(DefaultBodyLimit::max(16 * 1024)) + .with_state(HostState { + host, + bearer: bearer.into(), + })) +} +fn authorize(headers: &HeaderMap, state: &HostState) -> Result<(), StatusCode> { + let provided = headers + .get("authorization") + .and_then(|h| h.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(StatusCode::UNAUTHORIZED)?; + // Check every byte when lengths agree. Tokens never appear in URLs or errors. + if provided.len() != state.bearer.len() + || provided + .bytes() + .zip(state.bearer.bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + != 0 + { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(()) +} +fn status(error: HostError) -> StatusCode { + match error { + HostError::IdentityMismatch => StatusCode::CONFLICT, + HostError::Busy => StatusCode::CONFLICT, + HostError::ResourceLimit => StatusCode::TOO_MANY_REQUESTS, + HostError::Unavailable => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} +async fn command( + State(state): State, + Path(owner_id): Path, + headers: HeaderMap, + Json(command): Json, +) -> Result, StatusCode> { + authorize(&headers, &state)?; + // Retirement is a host/scheduler lifecycle command; observation cannot delete context. + if matches!(command.action, SessionControl::Retire) { + return Err(StatusCode::FORBIDDEN); + } + let handle = state + .host + .lookup(owner_id, command.generation) + .await + .map_err(status)?; + handle + .control(command.action) + .await + .map(Json) + .map_err(status) +} +async fn events( + State(state): State, + Path(owner_id): Path, + Query(binding): Query, + headers: HeaderMap, +) -> Result>>, StatusCode> { + authorize(&headers, &state)?; + let handle = state + .host + .lookup(owner_id, binding.generation) + .await + .map_err(status)?; + let mut events = handle.subscribe(); + let initial = handle.inspect().await.map_err(status)?; + let history = handle.source_history(); + let last_sequence = history + .events + .iter() + .filter_map(|event| match event { + SessionEvent::Source { frame, .. } => Some(frame.sequence), + _ => None, + }) + .max() + .unwrap_or(0); + let stream = async_stream::stream! { + yield Ok(Event::default().event("state").json_data(SessionEvent::State { snapshot: Box::new(initial) }).expect("serializable snapshot")); + if history.truncated { yield Ok(Event::default().event("gap").data("retained source history is incomplete")); } + for event in history.events { yield Ok(Event::default().event("session").json_data(event).expect("serializable source event")); } + loop { + match events.recv().await { + Ok(event) => { + if matches!(&event, SessionEvent::Source { frame, .. } if frame.sequence <= last_sequence) { continue; } + let ended = matches!(event, SessionEvent::Ended { .. }); + yield Ok(Event::default().event("session").json_data(event).expect("serializable session event")); + if ended { break; } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // Source events have no agent cursor; do not silently pretend loss is replayable. + yield Ok(Event::default().event("gap").data("subscriber lagged; reattach and inspect session state")); + break; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }; + Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))) +} diff --git a/crates/opensymphony-control/src/lib.rs b/crates/opensymphony-control/src/lib.rs index a09ced9cb..860ac456b 100644 --- a/crates/opensymphony-control/src/lib.rs +++ b/crates/opensymphony-control/src/lib.rs @@ -1,3 +1,5 @@ +mod acp; + use std::{convert::Infallible, sync::Arc, time::Duration}; use crate::opensymphony_domain::SnapshotEnvelope; @@ -91,11 +93,25 @@ impl SnapshotStore { #[derive(Debug, Clone)] pub struct ControlPlaneServer { store: SnapshotStore, + acp_router: Option, } impl ControlPlaneServer { pub fn new(store: SnapshotStore) -> Self { - Self { store } + Self { + store, + acp_router: None, + } + } + + /// Add private ACP observation commands/events. The host supplies a separate bearer. + pub fn with_acp_host( + mut self, + host: crate::opensymphony_acp::SessionHost, + bearer: String, + ) -> Result { + self.acp_router = Some(acp::router(host, bearer)?); + Ok(self) } pub fn router(&self) -> Router { @@ -105,6 +121,7 @@ impl ControlPlaneServer { .route("/api/v1/control/events", get(events)) .route("/api/v1/events", get(events)) .with_state(self.store.clone()) + .merge(self.acp_router.clone().unwrap_or_default()) } pub async fn serve(self, listener: TcpListener) -> std::io::Result<()> { diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index 6eb300ccd..7295be0b7 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -15,6 +15,7 @@ pub use manager::{ compose_terminal_prompt, }; pub use models::{ + AcpProcessState, AcpRecovery, AcpSessionIdentity, AcpSessionState, AcpSessionStatus, CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, CleanupIntent, CleanupOutcome, CleanupRequest, CleanupTarget, CleanupTerminalOutcome, CleanupTombstone, ConversationManifest, EnsureWorkspaceResult, HookConfig, HookDefinition, HookExecutionRecord, diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index 043a978a6..a042ce0b0 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -7012,7 +7012,7 @@ impl WorkspaceManager { Ok(()) } - async fn validate_workspace_owned_path( + pub(crate) async fn validate_workspace_owned_path( &self, workspace: &WorkspaceHandle, path: &Path, diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index 701076ec6..bede99581 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -105,6 +105,8 @@ pub struct TerminalRuntimeEnvelope { pub target_commit: String, pub instruction: InstructionProvenance, pub harness: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_session: Option, pub model_profile: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, @@ -1189,6 +1191,66 @@ impl RunManifest { } } +/// Host-owned ACP identity. Credential scope is an opaque grant revision, never +/// a credential value; the profile fingerprint hashes secret-free configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AcpSessionIdentity { + pub profile_id: String, + pub profile_fingerprint: String, + pub credential_scope: String, + pub workspace_path: PathBuf, + pub repository_binding: Option, + pub checkout_generation: Option, + pub generation: u64, + pub run_id: String, + pub attempt: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcpSessionStatus { + Ready, + Submitted, + Finished, + Uncertain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcpRecovery { + Fresh, + LiveAttach, + RestoredLoad, + RestoredResume, + TranscriptOnly, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcpProcessState { + LaunchPending, + Running { + pid: u32, + }, + #[default] + Stopped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AcpSessionState { + pub harness: String, + pub identity: AcpSessionIdentity, + pub session_id: Option, + /// Negotiated initialization metadata, redacted by the protocol client. + pub initialization: serde_json::Value, + pub status: AcpSessionStatus, + pub stop_reason: Option, + pub recovery: AcpRecovery, + pub owner_id: String, + #[serde(default)] + pub process: AcpProcessState, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConversationManifest { pub issue_id: String, @@ -1205,6 +1267,8 @@ pub struct ConversationManifest { pub runtime_contract_version: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_envelope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp: Option, } impl ConversationManifest { @@ -1227,6 +1291,7 @@ impl ConversationManifest { reset_reason: None, runtime_contract_version: runtime_contract_version.into(), runtime_envelope: None, + acp: None, } } } diff --git a/docs/architecture.md b/docs/architecture.md index b2ebee180..cd0e7cf42 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -482,10 +482,19 @@ Notable removals: The `opensymphony_acp` internal module uses the official Rust ACP SDK for typed requests, JSON-RPC correlation and ordered application dispatch. OpenSymphony owns the child process and supplies bounded LF framing to the SDK line transport. -The initial `run_turn` API creates one session, runs one prompt, and tears down -its child. It has no scheduler mutation authority. Host-owned retained sessions, -recovery and production worker routing are defined by the -[ACP runtime task package](tasks/acp-runtime-ide-task-package.yaml). +`SessionHost` owns a bounded registry of supervised per-issue sessions. Each +session actor accepts generation-fenced commands and allows one outstanding +prompt. Worker handles borrow a process across attempts; dropping a handle or +subscriber preserves the session. Idle expiry and explicit retirement use the +same supervised process teardown. The `run_turn` compatibility API creates one +session for one prompt. Neither API mutates scheduling state. Production worker +routing is the separate OSYM-902 integration slice. + +Durable ACP identity and submission/outcome markers live in the existing +conversation manifest, with additive ACP identity in its runtime envelope. +`ControlPlaneServer::with_acp_host` exposes authenticated observation commands +and ordered source events for a separate debug process. It does not grant IDE +writer control; scheduler holds and writer transfer belong to OSYM-907. Handlers are installed before initialization. Permission callbacks receive the protocol cancellation outcome; unknown requests receive method-not-found and diff --git a/docs/configuration.md b/docs/configuration.md index aa21397db..2a940cc3f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -956,7 +956,15 @@ Environment references remain names throughout migration. At launch, each source variable is removed from the child environment unless that name is also an explicit target in `env_refs`; only the intended credential aliases are passed. -The executable ACP client is available through `opensymphony_acp::run_turn`. +The executable ACP APIs are `opensymphony_acp::SessionHost` for retained sessions +and `opensymphony_acp::run_turn` for one-turn compatibility. Host callers supply +an explicit `RetentionPolicy`: defaults are eight sessions, 15 minutes idle +retention, 60-second renewable attachment leases, and 16 leases per session. +Active prompts and leases pin the process; capacity exhaustion refuses a new +launch. These host API settings are independent of profile protocol capabilities. +`SessionLaunch::require_persistence` rejects peers without negotiated load/resume +support. The caller supplies a non-secret credential/grant revision and the +scheduler's exact workspace identity; changes prevent session reuse. Production `opensymphony run` routing is a separate implementation slice; an ACP route currently fails scheduler capability selection before worker dispatch. Central profiles remain authoritative over repository-local workflow files. diff --git a/docs/operations.md b/docs/operations.md index 6c8d22c0a..b730e7fa0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -940,6 +940,25 @@ requested cancellation. Sending `session/cancel`, killing a process, or receivin an unrelated stop reason does not establish that acknowledgement. Prompt failures after possible submission are uncertain and are never retried by this client. +Retained ACP ownership is available through `SessionHost`. A finished worker may +borrow the same live process again; nonpersistent agents remain attachable while +that process is alive. Owner loss allows only capability-gated restoration of a +known-finished session. `uncertain` prompt outcomes and launch-checkpoint gaps +require execution-risk reconciliation before another process can launch. Do not +clear a manifest marker merely because a local process exited: delegated remote +work can remain active. + +An opted-in control server accepts a dedicated bearer token (at least 32 +non-whitespace characters). `POST /api/v1/acp/{owner_id}` accepts generation-bound +`inspect`, `attach`, `renew`, and `release` actions. Observation leases grant no +prompt authority. `GET /api/v1/acp/{owner_id}/events?generation=N` returns private +SSE state/source events and explicit history-gap events when a bounded buffer or +subscriber loses data. Tokens belong in the Authorization header. Source frames +preserve redacted content and unknown payloads, with connection generation, +arrival sequence, run binding and replay origin. Recorded history is bounded by +the client's queue count and byte budgets. Production CLI routing and IDE writer +handoff are separate integration slices. + ## Current model diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index ccc542b58..8ef1e0511 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -657,6 +657,16 @@ Run `cargo fmt --check` and `cargo clippy-system-duckdb`; dependency changes als require bundled-mode validation. Vendor interoperability evidence belongs to the later live-qualification task and must not be inferred from fake-peer tests. +`cargo test-system-duckdb --test acp_session_host` exercises the retained owner +with executable subprocesses: repeated attempts, concurrent issues, live leases, +idle expiry, cancellation/busy fencing, negotiated load/replay and resume, fresh +nonpersistent reset, durable uncertain-submission refusal and stale generations. +The peer verifies that the submitted marker is already on disk when a prompt +arrives. `cargo test-system-duckdb --lib opensymphony_acp::durable::tests` covers +owner locks, process-group loss, the launch checkpoint gap, compatibility bounds, +identity mismatches and legacy manifests. Workspace and control-plane regression +targets remain required for changes to persistence or the host observation seam. + ## Current model diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 2027b42fa..99149c32f 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -926,15 +926,32 @@ environment with scoped memory grants and checkout credential exclusions. The client clears ambient inheritance and rejects profile references that would reintroduce excluded credentials. -The initial client owns one process per `run_turn` call and reuses the workspace -process-group teardown helpers. It records local reaping independently of ACP -cancellation acknowledgement. `process_tree_signal_error` records any failed -process-group signal separately from the child exit; consumers must not infer -process-tree quiescence from child reaping alone. Local process termination does not prove delegated -remote work has stopped. Host session retention, durable run identity, recovery -and cleanup fencing are follow-on work in OSYM-901 and OSYM-902; this API does not -persist or replay prompts. Local process access remains trusted host execution, -not a sandbox. +`SessionHost` retains one supervised process/connection per issue. A stable +`.opensymphony/acp-owner.lock` prevents competing owners without creating another +session database. The conversation manifest records the profile fingerprint, +credential/grant revision, exact workspace/repository/checkout binding, opaque +session ID, run/attempt, connection generation, negotiated capabilities and +submission/outcome state. Native manifests remain readable and cannot be silently +replaced by ACP. Every command checks the owning connection generation. + +The manifest is atomically replaced and synced, including its parent directory, +before a prompt can reach the transport. `submitted` without a known terminal +outcome becomes `uncertain` and prevents automatic recovery or prompt replay. +Local EOF, process reaping and protocol cancellation acknowledgement are separate +evidence. Workspace cleanup remains fenced after an uncertain remote outcome. + +Recovery prefers advertised resume, then advertised load. Load source events are +tagged as replay through the load response; subsequent live frames keep their +own sequence and run binding. A finished nonpersistent session resets to a fresh +agent with an explicit full-context requirement. Its old transcript remains +inspection evidence. Unsupported required persistence rejects setup. + +On Unix, owner takeover also requires the prior process group to be absent. +A crash between the durable launch reservation and process-ID checkpoint leaves +a conservative launch-uncertain fence. Windows uses the existing kill-on-close +Job Object. Retirement waits for active work and leases to clear, then terminates +and reaps owned process resources before acknowledging cleanup. Trusted local +host execution provides filesystem/process access; it is not a sandbox. diff --git a/tests/acp_session_host.rs b/tests/acp_session_host.rs new file mode 100644 index 000000000..dcb8bc160 --- /dev/null +++ b/tests/acp_session_host.rs @@ -0,0 +1,669 @@ +use opensymphony::{opensymphony_acp::*, opensymphony_workspace::*}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + time::Duration, +}; +use tokio_util::sync::CancellationToken; + +async fn launch(root: &Path, issue: &str, mode: &str) -> SessionLaunch { + let root = root.canonicalize().expect("root"); + let manager = WorkspaceManager::new(WorkspaceManagerConfig { + root: root.clone(), + hooks: HookConfig::default(), + cleanup: CleanupConfig::default(), + }) + .expect("manager"); + let workspace = manager + .ensure(&IssueDescriptor { + issue_id: issue.into(), + identifier: issue.into(), + title: issue.into(), + current_state: "In Progress".into(), + last_seen_tracker_refresh_at: None, + repository_binding: None, + }) + .await + .expect("ensure") + .handle; + let identity = AcpSessionIdentity { + profile_id: "test".into(), + profile_fingerprint: String::new(), + credential_scope: "grant-generation-1".into(), + workspace_path: workspace.workspace_path().into(), + repository_binding: None, + checkout_generation: workspace.checkout_generation().map(str::to_owned), + generation: 0, + run_id: "first".into(), + attempt: 1, + }; + SessionLaunch { + context: LaunchContext { + workspace_root: root, + workspace_key: workspace.workspace_key().into(), + issue_workspace: workspace.workspace_path().into(), + environment: BTreeMap::from([("PATH".into(), std::env::var("PATH").expect("PATH"))]), + excluded_environment: BTreeSet::new(), + }, + manager, + workspace, + identity, + profile: AcpProfile { + command: "python3".into(), + args: vec![ + format!( + "{}/tests/fixtures/acp_session_peer.py", + env!("CARGO_MANIFEST_DIR") + ), + mode.into(), + ], + transport: "stdio".into(), + protocol_versions: vec![1], + env_refs: BTreeMap::new(), + auth: None, + required_capabilities: vec![], + extensions: vec![], + }, + limits: ClientLimits { + setup_timeout: Duration::from_secs(3), + prompt_timeout: Duration::from_secs(3), + cancel_timeout: Duration::from_secs(1), + ..ClientLimits::default() + }, + require_persistence: false, + } +} +async fn retire(handle: &SessionHandle) { + handle + .control(SessionControl::Retire) + .await + .expect("quiescent retirement"); +} +async fn prompt(handle: &SessionHandle, run: &str, text: &str) -> TurnReport { + handle + .prompt(run.into(), 1, text.into(), CancellationToken::new()) + .await + .expect("prompt") +} + +#[tokio::test] +async fn retained_owner_reuses_one_process_across_attempts_and_releasing_subscribers() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let first = host + .open(launch(root.path(), "ISSUE-1", "none").await) + .await + .expect("open"); + let ControlResult::Lease { lease_id, .. } = + first.control(SessionControl::Attach).await.expect("attach") + else { + panic!("lease") + }; + let events = first.subscribe(); + let id = prompt(&first, "attempt-1", "first").await.session_id; + drop(events); + first + .control(SessionControl::Release { lease_id }) + .await + .expect("release subscriber"); + let next = host + .open(launch(root.path(), "ISSUE-1", "none").await) + .await + .expect("borrow existing"); + assert_eq!(first.owner_id, next.owner_id); + drop(first); + assert_eq!(prompt(&next, "attempt-2", "second").await.session_id, id); + assert_eq!( + std::fs::read_to_string(root.path().join("ISSUE-1/launches")) + .expect("launches") + .lines() + .count(), + 1 + ); + let state = next.inspect().await.expect("snapshot"); + assert_eq!(state.state.identity.run_id, "attempt-2"); + assert_eq!(state.state.status, AcpSessionStatus::Finished); + retire(&next).await; +} + +#[tokio::test] +async fn concurrent_sessions_busy_prompt_fence_and_cancellation_keep_other_commands_responsive() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let (a, b) = tokio::join!( + host.open(launch(root.path(), "ISSUE-1", "none").await), + host.open(launch(root.path(), "ISSUE-2", "none").await) + ); + let (a, b) = (a.expect("a"), b.expect("b")); + let cancel = CancellationToken::new(); + let pending = tokio::spawn({ + let a = a.clone(); + let cancel = cancel.clone(); + async move { a.prompt("hang-run".into(), 1, "hang".into(), cancel).await } + }); + for _ in 0..100 { + if a.inspect().await.expect("inspect").state.status == AcpSessionStatus::Submitted { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(matches!( + a.prompt("overlap".into(), 1, "no".into(), CancellationToken::new()) + .await, + Err(HostError::Busy) + )); + assert!(matches!( + a.control(SessionControl::Retire).await, + Err(HostError::Busy) + )); + assert!(prompt(&b, "independent", "other issue").await.succeeded()); + cancel.cancel(); + assert!( + pending + .await + .expect("task") + .expect("cancelled") + .cancellation_acknowledged + ); + retire(&a).await; + retire(&b).await; +} + +#[tokio::test] +async fn retention_leases_expire_and_resource_limits_refuse_active_owners() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy { + max_sessions: 1, + idle_timeout: Duration::from_millis(100), + attachment_ttl: Duration::from_millis(300), + ..RetentionPolicy::default() + }) + .expect("host"); + let a = host + .open(launch(root.path(), "ISSUE-1", "none").await) + .await + .expect("open"); + let ControlResult::Lease { lease_id, .. } = + a.control(SessionControl::Attach).await.expect("attach") + else { + panic!("lease") + }; + tokio::time::sleep(Duration::from_millis(160)).await; + assert!(a.inspect().await.is_ok(), "attachment pins idle owner"); + assert!(matches!( + host.open(launch(root.path(), "ISSUE-2", "none").await) + .await, + Err(HostError::ResourceLimit) + )); + a.control(SessionControl::Renew { lease_id }) + .await + .expect("renew"); + tokio::time::sleep(Duration::from_millis(380)).await; + assert!( + a.inspect().await.is_err(), + "expired attachment allows visible retirement" + ); + let b = host + .open(launch(root.path(), "ISSUE-2", "none").await) + .await + .expect("slot reclaimed"); + tokio::time::sleep(Duration::from_millis(180)).await; + assert!( + b.inspect().await.is_err(), + "unattached idle session expires" + ); +} + +#[tokio::test] +async fn restoration_uses_only_negotiated_load_or_resume_and_tags_replay() { + for mode in ["load", "resume", "none"] { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let first = host + .open(launch(root.path(), "ISSUE-1", mode).await) + .await + .expect("open"); + let original = prompt(&first, "first", "hello").await.session_id; + retire(&first).await; + let next = host + .open(launch(root.path(), "ISSUE-1", mode).await) + .await + .expect("recovery"); + let state = next.inspect().await.expect("snapshot").state; + let expected = match mode { + "load" => AcpRecovery::RestoredLoad, + "resume" => AcpRecovery::RestoredResume, + _ => AcpRecovery::Fresh, + }; + assert_eq!(state.recovery, expected); + assert!(next.generation > first.generation); + if mode == "none" { + assert_ne!(state.session_id.as_deref(), Some(original.as_str())); + } else { + assert_eq!(state.session_id.as_deref(), Some(original.as_str())); + } + // Allow adjacent live notification through the ordered dispatcher. + tokio::time::sleep(Duration::from_millis(30)).await; + let history = next.source_history(); + assert!(!history.truncated); + let replayed = history.events.iter().filter(|event| matches!(event, SessionEvent::Source { replay: true, frame, .. } if frame.payload["params"]["update"]["content"]["text"] == "old history")).count(); + assert_eq!(replayed, usize::from(mode == "load")); + assert!(!history.events.iter().any(|event| matches!(event, SessionEvent::Source { replay: true, frame, .. } if frame.payload["params"]["update"]["content"]["text"] == "live adjacent to restore response"))); + let methods = + std::fs::read_to_string(root.path().join("ISSUE-1/methods")).expect("methods"); + assert_eq!( + methods + .lines() + .filter(|line| *line == "session/prompt") + .count(), + 1, + "restoration never replays prompt" + ); + retire(&next).await; + } +} + +#[tokio::test] +async fn uncertain_submission_survives_owner_loss_and_rejects_new_process() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let a = host + .open(launch(root.path(), "ISSUE-1", "load").await) + .await + .expect("open"); + let mut events = a.subscribe(); + assert!( + a.prompt( + "crash-run".into(), + 1, + "crash".into(), + CancellationToken::new() + ) + .await + .is_err() + ); + let ended = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let SessionEvent::Ended { + cleanup_ready, + detail, + .. + } = events.recv().await.expect("owner events") + { + break (cleanup_ready, detail); + } + } + }) + .await + .expect("ended event"); + assert!(!ended.0, "EOF/reaping is not remote quiescence"); + assert!(ended.1.is_some()); + tokio::time::sleep(Duration::from_millis(80)).await; + let next = SessionHost::new(RetentionPolicy::default()).expect("new host"); + assert!(matches!( + next.open(launch(root.path(), "ISSUE-1", "load").await) + .await, + Err(HostError::UncertainSubmission) + )); + let state: ConversationManifest = serde_json::from_slice( + &std::fs::read(root.path().join("ISSUE-1/.opensymphony/conversation.json")) + .expect("manifest"), + ) + .expect("JSON"); + assert!(matches!( + state.acp.expect("ACP").status, + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain + )); + assert_eq!( + std::fs::read_to_string(root.path().join("ISSUE-1/launches")) + .expect("launches") + .lines() + .count(), + 1 + ); +} + +#[tokio::test] +async fn duplicate_hosts_and_identity_or_generation_changes_cannot_reuse_session() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let a = host + .open(launch(root.path(), "ISSUE-1", "none").await) + .await + .expect("open"); + let second = SessionHost::new(RetentionPolicy::default()).expect("other host"); + assert!(matches!( + second + .open(launch(root.path(), "ISSUE-1", "none").await) + .await, + Err(HostError::AlreadyOwned) + )); + for field in ["profile", "grant", "workspace", "generation"] { + let mut changed = launch(root.path(), "ISSUE-1", "none").await; + match field { + "profile" => changed.identity.profile_id = "other".into(), + "grant" => changed.identity.credential_scope = "changed".into(), + "workspace" => changed.identity.workspace_path = root.path().into(), + _ => changed.identity.generation = a.generation + 1, + } + assert!( + matches!(host.open(changed).await, Err(HostError::IdentityMismatch)), + "{field}" + ); + } + let mut stale = a.clone(); + stale.generation += 1; + assert!(matches!( + stale.inspect().await, + Err(HostError::IdentityMismatch) + )); + assert!(matches!( + stale + .prompt("stale".into(), 1, "no".into(), CancellationToken::new()) + .await, + Err(HostError::IdentityMismatch) + )); + retire(&a).await; +} + +#[tokio::test] +async fn explicit_persistence_requirement_fails_before_prompt() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "ISSUE-1", "none").await; + request.require_persistence = true; + assert!(host.open(request).await.is_err()); + assert!( + !std::fs::read_to_string(root.path().join("ISSUE-1/methods")) + .expect("methods") + .contains("session/prompt") + ); +} + +#[tokio::test] +async fn host_control_plane_authenticates_and_reaches_same_owner_over_http() { + use opensymphony::{ + opensymphony_control::{ControlPlaneServer, SnapshotStore}, + opensymphony_domain::*, + }; + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(launch(root.path(), "ISSUE-1", "none").await) + .await + .expect("open"); + let now = chrono::Utc::now(); + let snapshot = ControlPlaneDaemonSnapshot { + generated_at: now, + daemon: ControlPlaneDaemonStatus { + state: ControlPlaneDaemonState::Ready, + last_poll_at: now, + workspace_root: String::new(), + status_line: String::new(), + }, + agent_server: ControlPlaneAgentServerStatus { + reachable: false, + base_url: String::new(), + conversation_count: 0, + status_line: String::new(), + }, + memory_server: Default::default(), + metrics: ControlPlaneMetricsSnapshot { + running_issues: 0, + retry_queue_depth: 0, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + total_cost_micros: 0, + }, + issues: vec![], + recent_events: vec![], + }; + let token = "private-host-bearer-for-hermetic-test-only"; + let server = ControlPlaneServer::new(SnapshotStore::new(snapshot)) + .with_acp_host(host, token.into()) + .expect("private routes"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listen"); + let addr = listener.local_addr().expect("address"); + let task = tokio::spawn(server.serve(listener)); + let url = format!("http://{addr}/api/v1/acp/{}", handle.owner_id); + let client = reqwest::Client::new(); + assert_eq!( + client + .post(&url) + .json(&serde_json::json!({"generation": handle.generation, "action": "inspect"})) + .send() + .await + .expect("response") + .status(), + 401 + ); + let observed = client + .post(&url) + .bearer_auth(token) + .json(&serde_json::json!({"generation": handle.generation, "action": "inspect"})) + .send() + .await + .expect("response"); + assert!(observed.status().is_success()); + let value: ControlResult = observed.json().await.expect("DTO"); + assert!( + matches!(value, ControlResult::Snapshot(snapshot) if snapshot.state.owner_id == handle.owner_id) + ); + let stale = client + .post(&url) + .bearer_auth(token) + .json(&serde_json::json!({"generation": handle.generation+1, "action": "attach"})) + .send() + .await + .expect("stale"); + assert_eq!(stale.status(), 409); + let retired = client + .post(&url) + .bearer_auth(token) + .json(&serde_json::json!({"generation": handle.generation, "action": "retire"})) + .send() + .await + .expect("retire denied"); + assert_eq!(retired.status(), 403); + let mut stream = client + .get(format!("{url}/events?generation={}", handle.generation)) + .bearer_auth(token) + .send() + .await + .expect("events"); + let first = tokio::time::timeout(Duration::from_secs(2), stream.chunk()) + .await + .expect("bounded stream") + .expect("read") + .expect("state"); + assert!(String::from_utf8_lossy(&first).contains("event: state")); + retire(&handle).await; + task.abort(); +} + +// Invoked only by the crash test as a separate runtime host process. +#[tokio::test] +async fn host_process_fixture() { + let Ok(root) = std::env::var("OSYM_ACP_CRASH_ROOT") else { + return; + }; + let root = std::path::PathBuf::from(root); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(launch(&root, "ISSUE-1", "resume").await) + .await + .expect("open"); + if std::env::var("OSYM_ACP_CRASH_PHASE").expect("phase") == "submitted" { + let pending = handle.clone(); + tokio::spawn(async move { + pending + .prompt( + "uncertain-run".into(), + 1, + "hang".into(), + CancellationToken::new(), + ) + .await + }); + loop { + if handle.inspect().await.expect("inspect").state.status == AcpSessionStatus::Submitted + { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + std::fs::write(root.join("host-ready"), "ready").expect("barrier"); + tokio::time::sleep(Duration::from_secs(60)).await; +} + +#[cfg(unix)] +#[tokio::test] +async fn real_host_crash_before_and_after_submission_preserves_recovery_boundary() { + for phase in ["ready", "submitted"] { + let root = tempfile::tempdir().expect("temp"); + let mut child = + std::process::Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", "host_process_fixture", "--nocapture"]) + .env("OSYM_ACP_CRASH_ROOT", root.path()) + .env("OSYM_ACP_CRASH_PHASE", phase) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("separate host"); + tokio::time::timeout(Duration::from_secs(10), async { + while !root.path().join("host-ready").exists() { + assert!( + child.try_wait().expect("poll").is_none(), + "host exited before checkpoint" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("host barrier"); + child.kill().expect("crash host"); + child.wait().expect("reap host"); + let manifest: ConversationManifest = serde_json::from_slice( + &std::fs::read(root.path().join("ISSUE-1/.opensymphony/conversation.json")) + .expect("manifest"), + ) + .expect("JSON"); + let AcpProcessState::Running { pid } = manifest.acp.expect("ACP").process else { + panic!("persisted child") + }; + let pid = rustix::process::Pid::from_raw(pid as i32).expect("pid"); + // Orphan process may still be winding down after inherited transport EOF. + tokio::time::timeout(Duration::from_secs(5), async { + while rustix::process::test_kill_process_group(pid) != Err(rustix::io::Errno::SRCH) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("peer observes host transport loss"); + let host = SessionHost::new(RetentionPolicy::default()).expect("replacement host"); + let recovered = host + .open(launch(root.path(), "ISSUE-1", "resume").await) + .await; + if phase == "ready" { + let recovered = recovered.expect("known quiescent state can restore"); + assert_eq!( + recovered.inspect().await.expect("state").state.recovery, + AcpRecovery::RestoredResume + ); + retire(&recovered).await; + } else { + assert!( + matches!(recovered, Err(HostError::UncertainSubmission)), + "ambiguous prompt stays fenced" + ); + assert_eq!( + std::fs::read_to_string(root.path().join("ISSUE-1/launches")) + .expect("launch count") + .lines() + .count(), + 1 + ); + } + } +} + +#[tokio::test] +async fn recorded_inspection_and_bounded_source_history_are_truthful() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let request = launch(root.path(), "ISSUE-1", "none").await; + let manager = + WorkspaceManager::new(request.manager.config().clone()).expect("inspection manager"); + let workspace = request.workspace.clone(); + let handle = host.open(request).await.expect("open"); + let text = format!(" code\n\t{} ", "x".repeat(4096)); + prompt(&handle, "live", &text).await; + assert!(handle.source_history().events.iter().any(|event| matches!(event, SessionEvent::Source { frame, .. } if frame.payload["params"]["update"]["content"]["text"] == text)), "source payload preserves long whitespace-bearing content"); + let state = handle.inspect().await.expect("live"); + assert_eq!(state.state.recovery, AcpRecovery::LiveAttach); + let mut requires = launch(root.path(), "ISSUE-1", "none").await; + requires.require_persistence = true; + assert!(matches!( + host.open(requires).await, + Err(HostError::PersistenceUnsupported) + )); + retire(&handle).await; + let recorded = inspect_recorded_session(&manager, &workspace) + .await + .expect("recorded"); + assert!(!recorded.live && !recorded.retirement_eligible); + assert_eq!(recorded.state.recovery, AcpRecovery::TranscriptOnly); + let mut bounded = launch(root.path(), "ISSUE-1", "none").await; + bounded.limits.queued_frames = 2; + bounded.limits.queued_bytes = 256; + let bounded = host.open(bounded).await.expect("bounded owner"); + let history = bounded.source_history(); + assert!(history.truncated); + assert!(history.events.len() <= 2); + retire(&bounded).await; +} + +#[tokio::test] +async fn missing_persisted_context_resets_only_when_persistence_is_optional() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(launch(root.path(), "ISSUE-1", "load").await) + .await + .expect("open"); + prompt(&handle, "finished", "hello").await; + retire(&handle).await; + std::fs::write(root.path().join("ISSUE-1/forget-session"), "").expect("forget"); + let mut required = launch(root.path(), "ISSUE-1", "load").await; + required.require_persistence = true; + assert!( + host.open(required).await.is_err(), + "required persistence never resets" + ); + let reset = host + .open(launch(root.path(), "ISSUE-1", "load").await) + .await + .expect("optional reset"); + assert_eq!( + reset.inspect().await.expect("fresh state").state.recovery, + AcpRecovery::Fresh + ); + retire(&reset).await; + let manifest: ConversationManifest = serde_json::from_slice( + &std::fs::read(root.path().join("ISSUE-1/.opensymphony/conversation.json")) + .expect("manifest"), + ) + .expect("JSON"); + assert!(manifest.fresh_conversation); + assert!( + manifest + .reset_reason + .expect("reset diagnostic") + .contains("persisted context") + ); +} diff --git a/tests/fixtures/acp_session_peer.py b/tests/fixtures/acp_session_peer.py new file mode 100644 index 000000000..ac4474a9a --- /dev/null +++ b/tests/fixtures/acp_session_peer.py @@ -0,0 +1,65 @@ +"""Retained-session and restoration peer; records effects for host lifecycle assertions.""" +import json +import os +import sys +import uuid + +mode = sys.argv[1] +session = str(uuid.uuid4()) +pending = None + + +def send(payload): + print(json.dumps({"jsonrpc": "2.0", **payload}), flush=True) + + +def update(text): + send({"method": "session/update", "params": {"sessionId": session, + "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}}}) + + +with open("launches", "a") as log: + log.write(str(os.getpid()) + "\n") +for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + with open("methods", "a") as log: + log.write(method + "\n") + if method == "initialize": + caps = {"loadSession": mode == "load"} + if mode == "resume": + caps["sessionCapabilities"] = {"resume": {}} + send({"id": message["id"], "result": {"protocolVersion": 1, "agentCapabilities": caps}}) + elif method == "session/new": + assert message["params"]["cwd"] == os.getcwd() + send({"id": message["id"], "result": {"sessionId": session}}) + elif method in ("session/load", "session/resume"): + assert method == "session/" + mode + if os.path.exists("forget-session"): + send({"id": message["id"], "error": {"code": -32002, "message": "session missing"}}) + continue + assert message["params"]["cwd"] == os.getcwd() + session = message["params"]["sessionId"] + if mode == "load": + update("old history") + send({"id": message["id"], "result": {}}) + update("live adjacent to restore response") + elif method == "session/prompt": + assert message["params"]["sessionId"] == session + # The marker must already exist on disk before even the first prompt byte is observed. + with open(".opensymphony/conversation.json") as file: + assert json.load(file)["acp"]["status"] == "submitted" + text = message["params"]["prompt"][0]["text"] + if text == "crash": + sys.exit(7) + if text == "hang": + pending = message["id"] + update("pending") + continue + update(text) + send({"id": message["id"], "result": {"stopReason": "end_turn"}}) + elif method == "session/cancel": + update("cancelling") + send({"id": pending, "result": {"stopReason": "cancelled"}}) + else: + raise AssertionError(method) From 3de8d3b097c07e0144584c73ad2b81f512acf75f Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Tue, 22 Sep 2026 08:00:52 -0500 Subject: [PATCH 14/29] docs(memory): capture COE-609 retained ACP sessions Capture merged session ownership and durable recovery, then synchronize the full 175-issue corpus. Public-doc lint and whitespace checks pass; all existing managed history is preserved. --- .opensymphony/memory/memory.yaml | 15 +++++++++++++++ docs/accessibility.md | 2 ++ docs/acp.md | 3 +++ docs/architecture.md | 2 ++ docs/build.md | 2 ++ docs/cli.md | 2 ++ docs/code-intelligence.md | 2 ++ docs/code-review.md | 2 ++ docs/codex-runtime.md | 2 ++ docs/codex.md | 2 ++ docs/configuration.md | 2 ++ docs/control.md | 2 ++ docs/data-contracts.md | 2 ++ docs/debugging.md | 2 ++ docs/desktop.md | 2 ++ docs/developer-experience.md | 2 ++ docs/docs.md | 2 ++ docs/documentation.md | 2 ++ docs/duckdb.md | 2 ++ docs/evidence.md | 2 ++ docs/experiments.md | 2 ++ docs/frontend.md | 2 ++ docs/gateway.md | 2 ++ docs/graph-view.md | 2 ++ docs/harness-runtime.md | 2 ++ docs/harness.md | 3 +++ docs/installer.md | 2 ++ docs/linear.md | 2 ++ docs/mcp.md | 2 ++ docs/memory.md | 2 ++ docs/migration.md | 2 ++ docs/model-configuration.md | 2 ++ docs/okf.md | 2 ++ docs/openhands-runtime.md | 2 ++ docs/operations.md | 2 ++ docs/orchestrator.md | 2 ++ docs/outcomes.md | 2 ++ docs/planning.md | 2 ++ docs/process-protocol.md | 2 ++ docs/provenance.md | 2 ++ docs/provider.md | 3 +++ docs/quality.md | 2 ++ docs/query-packs.md | 2 ++ docs/recovery.md | 3 +++ docs/release.md | 2 ++ docs/review.md | 3 +++ docs/run-detail.md | 2 ++ docs/runner.md | 2 ++ docs/runtime.md | 2 ++ docs/rust.md | 2 ++ docs/scheduler.md | 2 ++ docs/security.md | 2 ++ docs/sources.md | 2 ++ docs/target-repo-bootstrap.md | 2 ++ docs/task-packets.md | 2 ++ docs/template.md | 2 ++ docs/testing-and-operations.md | 2 ++ docs/testing.md | 2 ++ docs/tests.md | 2 ++ docs/threejs.md | 2 ++ docs/tui.md | 2 ++ docs/ui.md | 2 ++ docs/verifiers.md | 2 ++ docs/web.md | 2 ++ docs/workflow.md | 2 ++ docs/workspace-and-lifecycle.md | 2 ++ docs/workspace-lifecycle.md | 2 ++ docs/workspace.md | 2 ++ 68 files changed, 154 insertions(+) diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index 895dd07d2..35bd771a6 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -106,6 +106,7 @@ areas: - area:acp - area:configuration - area:runtime + - area:workspace linear_milestones: - 'M12.99: ACP Harness Runtime Adapter' ai-pr-review-human-setup: @@ -150,6 +151,7 @@ areas: - 'M12.8: Desktop App Installer And Auto-Update' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M6: Gateway And Stream Contract' - 'M7: Shared Client And Desktop Alpha' @@ -1548,6 +1550,7 @@ areas: - area:runtime - area:scheduler - area:testing + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -1816,6 +1819,7 @@ areas: - area:review - area:runtime - area:testing + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -2970,6 +2974,7 @@ areas: - area:runtime - area:testing - area:verifiers + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3090,6 +3095,7 @@ areas: - area:runner - area:runtime - area:testing + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3117,6 +3123,7 @@ areas: - area:runtime - area:task-packets - area:testing + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3139,6 +3146,7 @@ areas: - area:recovery - area:review - area:runtime + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3270,6 +3278,7 @@ areas: - area:configuration - area:recovery - area:runtime + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3385,6 +3394,7 @@ areas: - area:recovery - area:review - area:runtime + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3486,6 +3496,7 @@ areas: - area:runner - area:runtime - area:testing + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -3680,6 +3691,7 @@ areas: - area:runtime - area:scheduler - area:testing + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -4109,6 +4121,7 @@ areas: - 'M12.85: Codex Thread Lifecycle' - 'M12.95: Multi-Repository Foundations' - 'M12.96: Parent Integration Lifecycle' + - 'M12.99: ACP Harness Runtime Adapter' - 'M12.9: Code Graph View' - 'M6: Gateway And Stream Contract' - 'M7: Shared Client And Desktop Alpha' @@ -4484,6 +4497,7 @@ areas: - area:runtime - area:testing - area:verifiers + - area:workspace - area:workspace-lifecycle linear_milestones: - 'M12.96: Parent Integration Lifecycle' @@ -4695,6 +4709,7 @@ areas: - docs/workspace-and-lifecycle.md linear_milestones: - 'M10: Web Client And External Gateway' + - 'M12.99: ACP Harness Runtime Adapter' workspace-lifecycle: title: Workspace Lifecycle docs_target: docs/workspace-lifecycle.md diff --git a/docs/accessibility.md b/docs/accessibility.md index 8efb9cd9a..e7bd81ca9 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/acp.md b/docs/acp.md index f1e9548dc..50060506b 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -12,6 +12,7 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 ## Current model - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) +- COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) ## Important invariants @@ -29,9 +30,11 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 ## Recent changes - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs - COE-608 +- COE-609 diff --git a/docs/architecture.md b/docs/architecture.md index cd0e7cf42..6a6cc083f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -661,6 +661,7 @@ capabilities. No OpenHands server or client participates in this launch path. - COE-550: Per-Instance Memory Catalog And Source Migration - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-556: Bottom-Up Subtree Cleanup And Recovery +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -797,5 +798,6 @@ capabilities. No OpenHands server or client participates in this launch path. - COE-550 - COE-551 - COE-556 +- COE-609 diff --git a/docs/build.md b/docs/build.md index 96e6cb4fa..9ec448dd3 100644 --- a/docs/build.md +++ b/docs/build.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/cli.md b/docs/cli.md index 114415236..6d3dfe5da 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index d9c72d22c..2cc0289c9 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -284,6 +284,7 @@ references, and never returns source snippets. - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -344,5 +345,6 @@ references, and never returns source snippets. - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/code-review.md b/docs/code-review.md index 26274d4b1..f0f0361ba 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index 7deb97bcd..9262d78a1 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/codex.md b/docs/codex.md index 0d12d9fad..716ece555 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/configuration.md b/docs/configuration.md index 2a940cc3f..a55800435 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1180,6 +1180,7 @@ validation before legacy defaults can be applied. - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -1338,5 +1339,6 @@ validation before legacy defaults can be applied. - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/control.md b/docs/control.md index 15f997fa2..9f11f1873 100644 --- a/docs/control.md +++ b/docs/control.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 3b194d057..fd9dc4e0a 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -44,6 +44,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -58,5 +59,6 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/debugging.md b/docs/debugging.md index f232b1842..d19673630 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -144,5 +145,6 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/desktop.md b/docs/desktop.md index d7f4022bc..c0be61cb0 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -234,6 +235,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 - PR-196 - PR-197 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index 9c9e62a4e..218f15b10 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/docs.md b/docs/docs.md index 5738086d7..d6bdfd267 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery - PR-197: Graph visualization command center and shared fixtures ## Source refs @@ -233,6 +234,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 - PR-197 diff --git a/docs/documentation.md b/docs/documentation.md index 2509c7624..7ca76097e 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/duckdb.md b/docs/duckdb.md index 1e48594e9..8fc3af656 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/evidence.md b/docs/evidence.md index 14c41e984..88fb8bcde 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -44,6 +44,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -58,5 +59,6 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/experiments.md b/docs/experiments.md index d289471c0..8fe08612d 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-566: Implement configurable run matrices and scheduling - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-556 - COE-566 - COE-608 +- COE-609 diff --git a/docs/frontend.md b/docs/frontend.md index 00e65e5f0..3da88a6c6 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -154,6 +155,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 - PR-196 - PR-197 diff --git a/docs/gateway.md b/docs/gateway.md index 1eb569364..cc066bcd2 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -152,6 +152,7 @@ baseline, not an issue-workspace overlay. - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery - PR-196: Desktop task and diff latency plus live-refresh UX fixes ## Source refs @@ -254,6 +255,7 @@ baseline, not an issue-workspace overlay. - COE-566 - COE-567 - COE-608 +- COE-609 - PR-196 diff --git a/docs/graph-view.md b/docs/graph-view.md index d2d634b08..32c152a94 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -152,6 +153,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 - PR-196 - PR-197 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index 436aa5584..79a946d68 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/harness.md b/docs/harness.md index 72dad6bab..7ca65b250 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) +- COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-555 - COE-556 - COE-608 +- COE-609 diff --git a/docs/installer.md b/docs/installer.md index e65916f64..ac492891c 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/linear.md b/docs/linear.md index 7cb7dff4c..51271d9ba 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/mcp.md b/docs/mcp.md index 470ea3f6f..d8ea89fa2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/memory.md b/docs/memory.md index 099e15ac7..95c3e02e9 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -844,6 +844,7 @@ are covered by the same archive operation. - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -1010,5 +1011,6 @@ are covered by the same archive operation. - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/migration.md b/docs/migration.md index d0a76eead..8e4b94d90 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -144,5 +145,6 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index ad0317407..50eb87238 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/okf.md b/docs/okf.md index 6fbc7ada6..79ab2664d 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index e441bd8ca..77feb8800 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/operations.md b/docs/operations.md index b730e7fa0..8060c5d87 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1151,6 +1151,7 @@ handoff are separate integration slices. - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -1320,5 +1321,6 @@ handoff are separate integration slices. - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index 0288da51d..19bcad3bb 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/outcomes.md b/docs/outcomes.md index bc3098e9b..d5ae4443b 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-564: Implement verifier execution and outcome records - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-556 - COE-564 - COE-608 +- COE-609 diff --git a/docs/planning.md b/docs/planning.md index 356e89cd3..6475f076e 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index 4ee97bd3a..2ab2b3047 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-556 - COE-567 - COE-608 +- COE-609 diff --git a/docs/provenance.md b/docs/provenance.md index ce2818dd7..3098e92fa 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-563: Implement task-packet admission and freeze tooling - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-556 - COE-563 - COE-608 +- COE-609 diff --git a/docs/provider.md b/docs/provider.md index 5ad8aa669..b79f9b8b3 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) +- COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) ## Important invariants @@ -33,11 +34,13 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs - COE-555 - COE-556 - COE-608 +- COE-609 diff --git a/docs/quality.md b/docs/quality.md index ddd412cec..9a806efdf 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -44,6 +44,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -58,5 +59,6 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/query-packs.md b/docs/query-packs.md index f6f74694f..b03f9ffd7 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/recovery.md b/docs/recovery.md index def7f1d61..a7f8397ac 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) +- COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) ## Important invariants @@ -31,10 +32,12 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs - COE-556 - COE-608 +- COE-609 diff --git a/docs/release.md b/docs/release.md index dee0ae193..1570d61ad 100644 --- a/docs/release.md +++ b/docs/release.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -146,6 +147,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 ## 2.11 Rust toolchain boundary diff --git a/docs/review.md b/docs/review.md index b3fd94592..35dc59baf 100644 --- a/docs/review.md +++ b/docs/review.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-555 contributed: PR #238: COE-555: Add parent repair review and merge lifecycle (merge `52d3ba3`) - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) +- COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) ## Important invariants @@ -33,11 +34,13 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs - COE-555 - COE-556 - COE-608 +- COE-609 diff --git a/docs/run-detail.md b/docs/run-detail.md index e723ab787..a7b15dc5e 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/runner.md b/docs/runner.md index c2ac8ded0..d3998edc1 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-556 - COE-567 - COE-608 +- COE-609 diff --git a/docs/runtime.md b/docs/runtime.md index 113bf1feb..cf3be5108 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/rust.md b/docs/rust.md index ffbcf369b..a7e766807 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/scheduler.md b/docs/scheduler.md index 28164ec0b..ed80ac33d 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-566: Implement configurable run matrices and scheduling - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-556 - COE-566 - COE-608 +- COE-609 diff --git a/docs/security.md b/docs/security.md index 813391346..e30d25447 100644 --- a/docs/security.md +++ b/docs/security.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/sources.md b/docs/sources.md index 1ab737112..207eddbb9 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -354,6 +354,7 @@ Implementation and tests are in `crates/opensymphony-acp/src/lib.rs` and `tests/ - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-563: Implement task-packet admission and freeze tooling - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -436,5 +437,6 @@ Implementation and tests are in `crates/opensymphony-acp/src/lib.rs` and `tests/ - COE-551 - COE-563 - COE-608 +- COE-609 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index fa23436cd..6ffe465f6 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -144,5 +145,6 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/task-packets.md b/docs/task-packets.md index 0453483e3..2bd7848d5 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-563: Implement task-packet admission and freeze tooling - COE-565: Implement isolated workspace materialization - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-563 - COE-565 - COE-608 +- COE-609 diff --git a/docs/template.md b/docs/template.md index c9b0d49fa..748ceedc3 100644 --- a/docs/template.md +++ b/docs/template.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -144,5 +145,6 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 8ef1e0511..5d3b96df2 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -842,6 +842,7 @@ targets remain required for changes to persistence or the host observation seam. - COE-554: Restart-Safe Parent Integration Controller - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -994,5 +995,6 @@ targets remain required for changes to persistence or the host observation seam. - COE-554 - COE-555 - COE-556 +- COE-609 diff --git a/docs/testing.md b/docs/testing.md index d741a1346..4d4fa2f66 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -230,6 +231,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 - PR-196 - PR-197 diff --git a/docs/tests.md b/docs/tests.md index e858af331..3d97a242d 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/threejs.md b/docs/threejs.md index 345ee1561..df72ca232 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/tui.md b/docs/tui.md index 769bf45bc..b32d99632 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -222,5 +223,6 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/ui.md b/docs/ui.md index 049c18e19..0ba2941a4 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -126,6 +126,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -224,6 +225,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 - PR-196 - PR-197 diff --git a/docs/verifiers.md b/docs/verifiers.md index 0145fe49b..b4ccc89ed 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-564: Implement verifier execution and outcome records - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-556 - COE-564 - COE-608 +- COE-609 diff --git a/docs/web.md b/docs/web.md index 770721318..6f4691ff0 100644 --- a/docs/web.md +++ b/docs/web.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -144,5 +145,6 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/workflow.md b/docs/workflow.md index 88f531c87..978c710c6 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 99149c32f..4eeb4b2b6 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -996,6 +996,7 @@ host execution provides filesystem/process access; it is not a sandbox. - COE-407: Browser Transport And Remote Stream Protocols - COE-419: Hosted Auth Placeholders And Web Parity - COE-473: Desktop task graph dependency and run detail parity +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -1016,5 +1017,6 @@ host execution provides filesystem/process access; it is not a sandbox. - COE-407 - COE-419 - COE-473 +- COE-609 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index 091d25496..a0c84cf20 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -87,6 +87,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -144,5 +145,6 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-566 - COE-567 - COE-608 +- COE-609 diff --git a/docs/workspace.md b/docs/workspace.md index bc0fec599..1494ee704 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-565: Implement isolated workspace materialization - COE-608: ACP Profiles And Executable Protocol Client +- COE-609: ACP Session Ownership And Durable Recovery ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-556 - COE-565 - COE-608 +- COE-609 From cd683eac97b1111a9575159fa66df5140dd6a2c3 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Tue, 22 Sep 2026 19:44:34 -0500 Subject: [PATCH 15/29] feat(acp): implement host callbacks and session configuration (#242) * feat(acp): add host client facilities and session configuration Implement contained text callbacks and session-bound terminal supervision with bounded admission, asynchronous waits, cancellation and teardown. Keep host services immutable and scope each retained turn's callbacks. Apply explicit advertised session selections before prompting and attach required scoped MCP servers only over negotiated transports. Preserve ordered updates and redact resolved grants from captured evidence. Validation: system-DuckDB ACP integration and unit checks, full Rust suite, cargo fmt and all-target clippy; executable-peer lifecycle and policy cases. * fix(acp): close callback setup and credential safety gaps Reject Windows callback path reparse points, including junctions, using one production validator exercised by the native Windows CI harness. Redact resolved MCP credential option values from captured requests, echoed updates and stderr. Keep callback epoch admission cancellable and bounded inside the setup deadline so queued callbacks cannot trap setup. Validation: 2107 system-DuckDB tests passed, clippy and formatting clean. Windows helper and native junction regression cross-compile successfully; native Windows execution runs in CI after publication. * fix(acp): pin Windows paths and preserve configuration semantics Pin Windows path ancestors without write/delete sharing through callback I/O and terminal spawn; open leaves without following reparse points. Apply currently advertised category prerequisites before dependent configuration choices and preserve redacted setup RPC classification. Focused configuration regressions passed; Windows helpers cross-compile. Full combined validation follows the retained-session integration before publication, including native Windows CI. * fix(acp): request read access for Windows directory pins Native Windows regression demonstrated that attribute-only directory handles do not prevent rename despite excluding delete sharing. Request read access so the sharing check protects the pinned callback path. Validation: production helpers and tests cross-compile; fmt/diff clean. Native Windows rename/junction tests run on the published correction. * fix(acp): enforce callback cancellation and retained resource policy Pin Unix terminal cwd through child-only fchdir, reject credential-bearing MCP URLs, and resolve mode categories after model prerequisites. Bound encoded callback responses and include client limits in retained identity. Cancel active facility waits when the current turn is cancelled. The unsafe-code lint stays denied except for one documented pre_exec registration whose closure only calls async-signal-safe fchdir. Validation: 2140 tests passed across 38 suites, including native macOS cwd swap and queued-I/O cancellation regressions; clippy, fmt and diff clean. * fix(acp): stage callback writes and classify MCP secrets Stage filesystem writes beside the destination and promote only after a complete flush. Keep Unix descriptor-relative containment and file mode, Windows parent pins, and cleanup when a staged operation fails or cancels. Mask MCP environment and header values structurally in source captures. Register credential-named values for substring redaction so ordinary short settings do not reject launch commands or alter unrelated source text. Validation: 2144 tests passed across 38 suites; native macOS staging, permission and path-swap regressions; Windows helper cross-check; all-target clippy, fmt and diff clean. Native Windows atomic-write execution follows publication. * fix(acp): close Windows staging handles before promotion Drop both data handles before atomically persisting the staged path while keeping every parent directory pin through replacement. Preserve the native regression that caught the Windows sharing violation. Validation: Windows production helper cross-target check, fmt and diff checks passed. Native Windows verification follows publication. Unix behavior is unchanged from the passing 2144-test suite. * test(acp): isolate Windows pinned rename behavior * fix(acp): promote Windows writes through pinned file handles Use a same-directory NT rename that preserves parent sharing guards, and pin the stage against relocation until promotion. Mark cancelled stages for deletion by handle so outstanding writes cannot strand them. Remove the temporary native API diagnostic after it isolated the conflict. Add Windows-only SDK bindings and audited synchronous call wrappers, with native regressions for stage swaps, promotion errors, and delayed handle cleanup. Cross-target compilation and formatting pass; native regressions and bundled validation run in CI. * fix(acp): enable Windows stage creation access * fix(acp): fence reset callbacks and retained configuration Clear failed restoration bindings during ordered response dispatch and retire prior callback work before a fresh session can be bound. Reapply explicit model, mode and option choices before each retained prompt's durable submission, with cancellation and one setup deadline. Add executable regressions for abandoned load/resume callbacks, changed or removed choices, cancellation, deadlines and owner responsiveness. Restore saved Windows test permissions instead of clearing readonly. Validation: retained host 20 tests, Windows target Clippy/check, all-target Clippy, formatting and diff checks pass. Full suite result recorded in PR. * fix(acp): retire completed callbacks and protect captured file contents Close retained callback epochs in ordered prompt response dispatch and quiesce owned work before publishing Finished. Keep preparation frames bound to the accepted run even when setup fails before submission. Redact filesystem callback payloads structurally without changing the wire content. Reject MCP URL queries and excluded checkout credential values forwarded through attachment aliases. Tests: full system-DuckDB suite, ACP and retained-host regressions, all-target Clippy, cargo fmt and git diff checks. * fix(acp): release durable owner lock on drop Summary: - Explicitly unlock the durable owner file when its owner drops. Rationale: - A concurrent fork can briefly retain the inherited file description after the parent closes it, delaying the next owner's lock claim. - Keep the stable lock file and existing cross-process ownership check. Tests: - ACP library suite passed 20 consecutive parallel runs. - Full system-DuckDB suite: 2,151 passed, 0 failed, 4 ignored. - All-target Clippy, cargo fmt, and git diff checks passed. * fix(acp): close initial callbacks and mask terminal evidence Summary: - Start services with callback authority closed until begin_turn, including the missing-session restoration fallback. - Mask terminal output in captured response clones while preserving the original peer-visible response. - Cover pre-prompt denial and opaque terminal output with executable peers. Rationale: - Session binding and configuration do not grant callback authority before the first prompt, and workspace-derived output must stay out of evidence. Tests: - Full system-DuckDB suite: 2,152 passed, 0 failed, 4 ignored. - ACP client 41/41 and retained host 23/23 passed. - Pre-prompt test failed when initial cancellation was removed and passed when restored; all-target Clippy, fmt and diff checks passed. * fix(acp): close callback and capture gaps Summary: - Pin the Unix workspace root across agent launch and service callbacks. - Revoke one-turn callbacks at response dispatch and settle cancellation after the durable submission checkpoint without sending a prompt. - Mask all captured MCP stdio arguments and redact generic headers. - Cover root replacement, late callbacks, retained cancellation, and credential-bearing MCP arguments in executable regression tests. Rationale: - Path replacement, response adjacency, opaque argv, and cancellation during sync could cross the bound workspace or turn authority. Tests: - cargo test-system-duckdb (2156 passed, 0 failed, 4 ignored) - cargo clippy-system-duckdb (all targets, warnings denied) - cargo fmt --all -- --check; git diff --check * fix(acp): verify terminal group teardown after natural exit Summary: - Retry macOS process-group EPERM briefly after terminal exit, accepting cleanup only after signaling succeeds or an absent group is proven. - Exercise repeated short-lived terminals in retained prompt cycles. - Give the lease-expiry test enough time to renew during parallel setup. Rationale: - Darwin can return EPERM for a reaping group; treating it as a terminal failure made retained callback tests intermittently lose their peer. - Persistent EPERM still reports teardown failure for inaccessible live descendants. The lease test still asserts expiry after its full TTL. Tests: - cargo test-system-duckdb (2156 passed, 0 failed, 4 ignored) - retained-host integration suite 30 default-parallel runs (all 24 pass) - cargo clippy-system-duckdb (all targets, warnings denied) - cargo fmt --all -- --check; git diff --check --- Cargo.lock | 1 + Cargo.toml | 3 +- crates/opensymphony-acp/src/atomic_file.rs | 371 +++++ crates/opensymphony-acp/src/durable.rs | 26 +- crates/opensymphony-acp/src/host.rs | 136 +- crates/opensymphony-acp/src/lib.rs | 403 +++++- crates/opensymphony-acp/src/services.rs | 1251 +++++++++++++++++ crates/opensymphony-acp/src/session_config.rs | 281 ++++ crates/opensymphony-acp/src/windows_atomic.rs | 125 ++ crates/opensymphony-acp/src/windows_path.rs | 150 ++ crates/opensymphony-workflow/src/acp.rs | 28 + crates/opensymphony-workflow/src/lib.rs | 2 +- docs/architecture.md | 11 +- docs/configuration.md | 49 +- docs/harness-adapter-compatibility.md | 21 +- docs/operations.md | 13 + docs/sources.md | 14 +- docs/testing-and-operations.md | 52 +- docs/workspace-and-lifecycle.md | 64 +- scripts/validation/check-acp-windows.py | 11 +- tests/acp.rs | 743 ++++++++++ tests/acp_session_host.rs | 599 +++++++- tests/fixtures/acp_services_peer.py | 262 ++++ tests/fixtures/acp_session_peer.py | 148 +- 24 files changed, 4689 insertions(+), 75 deletions(-) create mode 100644 crates/opensymphony-acp/src/atomic_file.rs create mode 100644 crates/opensymphony-acp/src/services.rs create mode 100644 crates/opensymphony-acp/src/session_config.rs create mode 100644 crates/opensymphony-acp/src/windows_atomic.rs create mode 100644 crates/opensymphony-acp/src/windows_path.rs create mode 100644 tests/fixtures/acp_services_peer.py diff --git a/Cargo.lock b/Cargo.lock index 50748b3d5..33ba4022f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2550,6 +2550,7 @@ dependencies = [ "unicode-width", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7bf9e995b..958b607a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ rustix = { workspace = true, features = ["fs"] } [target.'cfg(windows)'.dependencies] process-wrap = { version = "=10.0.0", default-features = false, features = ["tokio1", "job-object", "kill-on-drop"] } +windows-sys = { version = "=0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_Storage_FileSystem", "Win32_System_IO"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } @@ -86,7 +87,7 @@ version = "2.11.3" [workspace.lints.rust] rust_2018_idioms = { level = "warn", priority = -1 } -unsafe_code = "forbid" +unsafe_code = "deny" unused_lifetimes = "warn" unused_macro_rules = "warn" unused_qualifications = "warn" diff --git a/crates/opensymphony-acp/src/atomic_file.rs b/crates/opensymphony-acp/src/atomic_file.rs new file mode 100644 index 000000000..fda73949a --- /dev/null +++ b/crates/opensymphony-acp/src/atomic_file.rs @@ -0,0 +1,371 @@ +//! Cancel-safe callback writes: only a completed stage may replace the destination. +use std::{io, path::Path}; +use tokio::fs::File; +#[cfg(windows)] +#[path = "windows_atomic.rs"] +mod windows_atomic; + +pub(super) struct AtomicFile { + pub file: File, + #[cfg(unix)] + directory: std::os::fd::OwnedFd, + #[cfg(unix)] + temporary: std::ffi::OsString, + #[cfg(unix)] + destination: std::ffi::OsString, + #[cfg(not(unix))] + temporary: Option, + #[cfg(not(unix))] + destination: std::path::PathBuf, + #[cfg(windows)] + _guards: Vec, +} +impl AtomicFile { + #[cfg(any(not(unix), test))] + pub async fn new(root: &Path, path: &Path) -> io::Result { + #[cfg(unix)] + { + use rustix::fs::{Mode, OFlags, open}; + let root_directory = open( + root, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + Self::new_pinned(root, path, &root_directory).await + } + #[cfg(not(unix))] + { + let _ = root; + let parent = path.parent().ok_or(io::ErrorKind::InvalidInput)?; + #[cfg(windows)] + let guards = super::windows_path::pin_directory(parent, true).await?; + #[cfg(not(windows))] + tokio::fs::create_dir_all(parent).await?; + let permissions = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(io::ErrorKind::InvalidInput.into()); + } + } + if !metadata.is_file() || metadata.permissions().readonly() { + return Err(io::ErrorKind::InvalidInput.into()); + } + Some(metadata.permissions()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + #[cfg(not(windows))] + let temporary = tempfile::Builder::new() + .prefix(".opensymphony-write-") + .tempfile_in(parent)?; + #[cfg(windows)] + let temporary = tempfile::Builder::new() + .prefix(".opensymphony-write-") + .make_in(parent, windows_atomic::create_stage)?; + if let Some(permissions) = permissions { + temporary.as_file().set_permissions(permissions)?; + } + let file = File::from_std(temporary.as_file().try_clone()?); + Ok(Self { + file, + temporary: Some(temporary), + destination: path.into(), + #[cfg(windows)] + _guards: guards, + }) + } + } + + #[cfg(unix)] + pub async fn new_pinned( + root: &Path, + path: &Path, + root_directory: &std::os::fd::OwnedFd, + ) -> io::Result { + use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, mkdirat, openat}; + let relative = path + .strip_prefix(root) + .map_err(|_| io::ErrorKind::InvalidInput)?; + let destination = relative + .file_name() + .ok_or(io::ErrorKind::InvalidInput)? + .to_owned(); + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut directory = openat(root_directory, ".", flags, Mode::empty())?; + for component in relative + .parent() + .ok_or(io::ErrorKind::InvalidInput)? + .components() + { + if !matches!(component, std::path::Component::Normal(_)) { + return Err(io::ErrorKind::InvalidInput.into()); + } + match mkdirat( + &directory, + component.as_os_str(), + Mode::from_raw_mode(0o700), + ) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(error) => return Err(error.into()), + } + directory = openat(&directory, component.as_os_str(), flags, Mode::empty())?; + } + // Preserve the original file's write authorization and permissions, + // without truncating it or following a substituted leaf symlink. + let permissions = match openat( + &directory, + &destination, + OFlags::WRONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(file) => { + let metadata = fstat(&file)?; + if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile { + return Err(io::ErrorKind::InvalidInput.into()); + } + Some(Mode::from_raw_mode(metadata.st_mode)) + } + Err(rustix::io::Errno::NOENT) => None, + Err(error) => return Err(error.into()), + }; + let temporary = + std::ffi::OsString::from(format!(".opensymphony-write-{}", uuid::Uuid::new_v4())); + let file = openat( + &directory, + &temporary, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + )?; + let result = Self { + file: File::from_std(std::fs::File::from(file)), + directory, + temporary, + destination, + }; + if let Some(permissions) = permissions { + fchmod(&result.file, permissions)?; + } + Ok(result) + } + + // No await between the caller's last cancellation check and atomic replace. + pub fn commit(mut self) -> io::Result<()> { + #[cfg(unix)] + { + rustix::fs::renameat( + &self.directory, + &self.temporary, + &self.directory, + &self.destination, + )?; + self.temporary.clear(); + } + #[cfg(windows)] + { + let temporary = self.temporary.as_mut().ok_or(io::ErrorKind::InvalidInput)?; + windows_atomic::replace_in_same_directory( + temporary.as_file(), + self.destination + .file_name() + .ok_or(io::ErrorKind::InvalidInput)?, + )?; + // The original name no longer refers to our stage. Do not attempt + // path cleanup, which could remove another file created there. + temporary.disable_cleanup(true); + self.temporary.take(); + } + #[cfg(not(any(unix, windows)))] + { + // Other platforms use the tempfile crate's atomic promotion. + drop(self.file); + self.temporary + .take() + .ok_or(io::ErrorKind::InvalidInput)? + .into_temp_path() + .persist(&self.destination) + .map_err(|error| error.error)?; + } + Ok(()) + } +} +#[cfg(windows)] +impl Drop for AtomicFile { + fn drop(&mut self) { + if let Some(temporary) = self.temporary.as_mut() { + // Cancellation may leave a Tokio blocking write holding a cloned + // handle. Deletion by handle completes when its last handle closes. + if windows_atomic::delete_on_close(temporary.as_file()).is_ok() { + temporary.disable_cleanup(true); + } + } + } +} +#[cfg(unix)] +impl Drop for AtomicFile { + fn drop(&mut self) { + if !self.temporary.is_empty() { + let _ = rustix::fs::unlinkat( + &self.directory, + &self.temporary, + rustix::fs::AtFlags::empty(), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + + #[tokio::test] + async fn partial_stage_drop_preserves_original_and_completed_stage_replaces_it() { + let root = tempfile::tempdir().expect("root"); + let root = root.path().canonicalize().expect("canonical"); + let path = root.join("file"); + std::fs::write(&path, "original").expect("original"); + let mut stage = AtomicFile::new(&root, &path).await.expect("stage"); + stage + .file + .write_all(b"partial") + .await + .expect("partial write"); + stage.file.flush().await.expect("flush partial"); + assert_eq!( + std::fs::read(&path).expect("original survives staging"), + b"original" + ); + drop(stage); + assert_eq!(std::fs::read_dir(&root).expect("entries").count(), 1); + assert_eq!( + std::fs::read(&path).expect("cancel leaves original"), + b"original" + ); + let mut stage = AtomicFile::new(&root, &path).await.expect("stage"); + stage.file.write_all(b"complete").await.expect("write"); + stage.file.flush().await.expect("flush"); + stage.commit().expect("commit"); + assert_eq!(std::fs::read(&path).expect("replaced"), b"complete"); + assert_eq!(std::fs::read_dir(&root).expect("entries").count(), 1); + } + + #[tokio::test] + async fn stage_io_error_preserves_original_and_removes_temporary_file() { + let root = tempfile::tempdir().expect("root"); + let root = root.path().canonicalize().expect("canonical"); + let path = root.join("file"); + std::fs::write(&path, "original").expect("original"); + let mut stage = AtomicFile::new(&root, &path).await.expect("stage"); + #[cfg(unix)] + let temporary_path = root.join(&stage.temporary); + #[cfg(not(unix))] + let temporary_path = stage.temporary.as_ref().expect("stage").path().to_owned(); + // Reopen the actual stage read-only to deterministically inject a write + // failure at the same I/O boundary used by the callback. + stage.file = File::from_std(std::fs::File::open(&temporary_path).expect("read-only stage")); + assert!( + stage.file.write_all(b"replacement").await.is_err() + || stage.file.flush().await.is_err() + ); + drop(stage); + assert_eq!(std::fs::read(&path).expect("unchanged"), b"original"); + assert_eq!(std::fs::read_dir(&root).expect("entries").count(), 1); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_promotion_pins_stage_and_parent_and_preserves_original_on_error() { + let root = tempfile::tempdir().expect("root"); + let root = root.path().canonicalize().expect("canonical"); + let inside = root.join("inside"); + std::fs::create_dir(&inside).expect("directory"); + let path = inside.join("file"); + std::fs::write(&path, "original").expect("original"); + let mut stage = AtomicFile::new(&root, &path).await.expect("stage"); + let temporary = stage.temporary.as_ref().expect("stage").path(); + assert!(std::fs::rename(temporary, root.join("escaped-stage")).is_err()); + assert!(std::fs::rename(&inside, root.join("moved")).is_err()); + stage.file.write_all(b"complete").await.expect("write"); + stage.file.flush().await.expect("flush"); + let permissions = std::fs::metadata(&path).expect("metadata").permissions(); + let mut readonly = permissions.clone(); + readonly.set_readonly(true); + std::fs::set_permissions(&path, readonly).expect("readonly"); + assert!(stage.commit().is_err(), "readonly destination must survive"); + assert_eq!(std::fs::read(&path).expect("original"), b"original"); + assert_eq!(std::fs::read_dir(&inside).expect("entries").count(), 1); + assert!( + std::fs::metadata(&path) + .expect("metadata") + .permissions() + .readonly() + ); + std::fs::set_permissions(&path, permissions).expect("restore for cleanup"); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_cancel_defers_stage_deletion_until_outstanding_write_handle_closes() { + let root = tempfile::tempdir().expect("root"); + let root = root.path().canonicalize().expect("canonical"); + let path = root.join("file"); + std::fs::write(&path, "original").expect("original"); + let stage = AtomicFile::new(&root, &path).await.expect("stage"); + // Tokio's blocking write owns a clone of this file object after its + // future is cancelled. Keep the same ownership alive deterministically. + let mut inflight = stage + .temporary + .as_ref() + .expect("stage") + .as_file() + .try_clone() + .expect("in-flight handle"); + drop(stage); + std::io::Write::write_all(&mut inflight, b"late staged output").expect("late write"); + drop(inflight); + assert_eq!(std::fs::read(&path).expect("original"), b"original"); + assert_eq!(std::fs::read_dir(&root).expect("entries").count(), 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn atomic_write_keeps_permissions_and_descriptor_relative_parent() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().expect("root"); + let root = root.path().canonicalize().expect("canonical"); + let inside = root.join("inside"); + std::fs::create_dir(&inside).expect("inside"); + let path = inside.join("file"); + std::fs::write(&path, "original").expect("original"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).expect("mode"); + let mut stage = AtomicFile::new(&root, &path).await.expect("stage"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::write(outside.path().join("file"), "outside").expect("outside"); + std::fs::rename(&inside, root.join("moved")).expect("rename"); + std::os::unix::fs::symlink(outside.path(), &inside).expect("swap"); + stage.file.write_all(b"complete").await.expect("write"); + stage.file.flush().await.expect("flush"); + stage.commit().expect("commit into held parent"); + assert_eq!( + std::fs::read(root.join("moved/file")).expect("file"), + b"complete" + ); + assert_eq!( + std::fs::metadata(root.join("moved/file")) + .expect("mode") + .permissions() + .mode() + & 0o777, + 0o640 + ); + assert_eq!( + std::fs::read(outside.path().join("file")).expect("outside"), + b"outside" + ); + assert!(AtomicFile::new(&root, &path).await.is_err()); + } +} diff --git a/crates/opensymphony-acp/src/durable.rs b/crates/opensymphony-acp/src/durable.rs index 4473b1b28..fc5c843f4 100644 --- a/crates/opensymphony-acp/src/durable.rs +++ b/crates/opensymphony-acp/src/durable.rs @@ -41,8 +41,22 @@ impl From for DurabilityError { } } -pub(super) fn profile_fingerprint(profile: &AcpProfile) -> Result { - let encoded = serde_json::to_vec(profile).map_err(|_| DurabilityError::InvalidMetadata)?; +pub(super) fn profile_fingerprint( + profile: &AcpProfile, + services: &super::HostServices, + limits: &super::ClientLimits, +) -> Result { + // Persist only the digest: resolved MCP grants are part of session reuse + // identity but must never enter durable manifests in cleartext. + let encoded = serde_json::to_vec(&( + profile, + services.read_files, + services.write_files, + services.terminals, + &services.mcp_servers, + limits, + )) + .map_err(|_| DurabilityError::InvalidMetadata)?; Ok(format!("{:x}", Sha256::digest(encoded))) } @@ -54,6 +68,14 @@ pub(super) struct Durability { _owner_lock: File, } +impl Drop for Durability { + fn drop(&mut self) { + // Release explicitly: a concurrent fork can temporarily inherit an open + // descriptor before exec closes it, delaying close-only flock release. + let _ = self._owner_lock.unlock(); + } +} + impl Durability { /// Zero generation is the trusted host's request to claim the latest record. /// A nonzero generation requires an exact match before reserving its successor. diff --git a/crates/opensymphony-acp/src/host.rs b/crates/opensymphony-acp/src/host.rs index 0408c9268..4e66cc65f 100644 --- a/crates/opensymphony-acp/src/host.rs +++ b/crates/opensymphony-acp/src/host.rs @@ -428,7 +428,11 @@ impl SessionHost { } HostCommand::Open { mut launch, reply } => { let key = launch.workspace.issue_id().to_owned(); - let fingerprint = profile_fingerprint(&launch.profile); + let fingerprint = profile_fingerprint( + &launch.profile, + &launch.context.services, + &launch.limits, + ); let Ok(fingerprint) = fingerprint else { let _ = reply.send(Err(HostError::Persistence)); continue; @@ -652,6 +656,17 @@ struct ActivePrompt { cancellation: CancellationToken, } +struct PreparingPrompt { + result: Pin> + Send>>, + run_id: String, + attempt: u32, + prompt: String, + cancellation: CancellationToken, + forced: CancellationToken, + callback_epoch: CancellationToken, + reply: oneshot::Sender>, +} + pub(super) struct SessionDriver { durable: Durability, receive: mpsc::Receiver, @@ -739,6 +754,7 @@ impl SessionDriver { snapshot: Box::new(self.snapshot(active)), }); } + #[allow(clippy::too_many_arguments)] pub(super) async fn drive( &mut self, connection: ConnectionTo, @@ -746,6 +762,9 @@ impl SessionDriver { session_id: SessionId, capture: &SharedCapture, limits: &ClientLimits, + services: &super::services::CallbackSender, + configuration: &Arc>, + profile: &AcpProfile, ) -> Result { let mut metadata = serde_json::to_value(&initialization) .map_err(|_| ClientError::Setup("invalid negotiation metadata".into()))?; @@ -789,14 +808,53 @@ impl SessionDriver { } self.publish(false); let mut active: Option = None; + let mut preparing: Option = None; let mut tick = tokio::time::interval(Duration::from_millis(50)); let mut commands_closed = false; loop { tokio::select! { + result = async { preparing.as_mut().expect("guarded preparation").result.as_mut().await }, if preparing.is_some() => { + let pending = preparing.take().expect("preparing prompt"); + let result = result.and_then(|()| { + if pending.cancellation.is_cancelled() || pending.forced.is_cancelled() { Err(ClientError::CancelledBeforePrompt) } else { Ok(()) } + }); + if let Err(error) = result { + let _ = pending.reply.send(Err(HostError::Client(error.to_string()))); + return Err(error); + } + if self.durable.submitted(pending.run_id.clone(), pending.attempt).await.is_err() { + let _ = pending.reply.send(Err(HostError::Persistence)); + return Err(ClientError::Setup("submission checkpoint failed".into())); + } + if pending.cancellation.is_cancelled() || pending.forced.is_cancelled() { + pending.callback_epoch.cancel(); + if self.durable.finished("cancelled_before_prompt".into()).await.is_err() { + let _ = pending.reply.send(Err(HostError::Persistence)); + return Err(ClientError::Setup("cancellation checkpoint failed".into())); + } + let error = ClientError::CancelledBeforePrompt; + let _ = pending.reply.send(Err(HostError::Client(error.to_string()))); + return Err(error); + } + active = Some(ActivePrompt { + result: Box::pin(prompt_rpc(connection.clone(), initialization.clone(), session_id.clone(), pending.prompt, pending.cancellation, pending.forced.clone(), capture.clone(), limits.clone(), configuration.clone(), pending.callback_epoch, services.clone())), + reply: pending.reply, cancellation: pending.forced, + }); + self.publish(true); + } result = async { active.as_mut().expect("guarded active prompt").result.as_mut().await }, if active.is_some() => { let pending = active.take().expect("active prompt"); let report = match result { Ok(report) => report, + Err(ClientError::CancelledBeforePrompt) => { + if self.durable.finished("cancelled_before_prompt".into()).await.is_err() { + let _ = pending.reply.send(Err(HostError::Persistence)); + return Err(ClientError::Setup("cancellation checkpoint failed".into())); + } + let error = ClientError::CancelledBeforePrompt; + let _ = pending.reply.send(Err(HostError::Client(error.to_string()))); + return Err(error); + } Err(error) => { let _ = self.durable.uncertain().await; let _ = pending.reply.send(Err(HostError::Client(error.to_string()))); return Err(error); } }; if self.durable.finished(report.stop_reason.clone()).await.is_err() { @@ -811,25 +869,48 @@ impl SessionDriver { let Some(command) = command else { commands_closed = true; if let Some(active) = &active { active.cancellation.cancel(); } + if let Some(preparing) = &preparing { preparing.forced.cancel(); } continue; }; match command { Command::Prompt { generation, run_id, attempt, prompt, cancellation, reply } => { if generation != self.durable.state().identity.generation { let _ = reply.send(Err(HostError::IdentityMismatch)); continue; } - if active.is_some() { let _ = reply.send(Err(HostError::Busy)); continue; } + if active.is_some() || preparing.is_some() { let _ = reply.send(Err(HostError::Busy)); continue; } if prompt.len() > limits.frame_bytes / 6 || run_id.is_empty() || run_id.len() > 1024 { let _ = reply.send(Err(HostError::ResourceLimit)); continue; } if cancellation.is_cancelled() { let _ = reply.send(Err(HostError::Client(ClientError::CancelledBeforePrompt.to_string()))); continue; } - if self.durable.submitted(run_id.clone(), attempt).await.is_err() { let _ = reply.send(Err(HostError::Persistence)); return Err(ClientError::Setup("submission checkpoint failed".into())); } - self.publisher.binding.lock().unwrap_or_else(|e| e.into_inner()).1 = run_id; - let forced = CancellationToken::new(); - active = Some(ActivePrompt { result: Box::pin(prompt_rpc(connection.clone(), initialization.clone(), session_id.clone(), prompt, cancellation, forced.clone(), capture.clone(), limits.clone())), reply, cancellation: forced }); + let forced = cancellation.child_token(); + let callback_epoch = forced.child_token(); + let sender = services.clone(); + let epoch = callback_epoch.clone(); + let timeout = limits.setup_timeout; + let connection = connection.clone(); + let profile = profile.clone(); + let session_id = session_id.clone(); + let configuration = configuration.clone(); + let capture = capture.clone(); + // Preparation frames belong to this accepted run even if setup + // fails before any durable prompt submission occurs. + self.publisher.binding.lock().unwrap_or_else(|e| e.into_inner()).1 = run_id.clone(); + preparing = Some(PreparingPrompt { + result: Box::pin(async move { + tokio::select! { + biased; + _ = epoch.cancelled() => Err(ClientError::CancelledBeforePrompt), + result = tokio::time::timeout(timeout, async { + sender.begin_turn(epoch.clone(), timeout).await?; + super::session_config::apply(&connection, &profile, session_id.0.as_ref(), &configuration, &capture).await + }) => result.map_err(|_| ClientError::SetupTimeout)?, + } + }), + run_id, attempt, prompt, cancellation, forced, callback_epoch, reply, + }); self.publish(true); } Command::Control { generation, action, reply } => { if generation != self.durable.state().identity.generation { let _ = reply.send(Err(HostError::IdentityMismatch)); continue; } self.expire_leases(); let result = match action { - SessionControl::Inspect => Ok(ControlResult::Snapshot(Box::new(self.snapshot(active.is_some())))), + SessionControl::Inspect => Ok(ControlResult::Snapshot(Box::new(self.snapshot(active.is_some() || preparing.is_some())))), SessionControl::Attach => { if self.attachments.len() >= self.policy.max_attachments { Err(HostError::ResourceLimit) } else { let lease_id = uuid::Uuid::new_v4().to_string(); @@ -843,7 +924,7 @@ impl SessionDriver { }, SessionControl::Release { lease_id } => if self.attachments.remove(&lease_id).is_some() { self.idle_since = tokio::time::Instant::now(); Ok(ControlResult::Released) } else { Err(HostError::IdentityMismatch) }, SessionControl::Retire => { - if active.is_some() || !self.attachments.is_empty() { Err(HostError::Busy) } else { self.retire_reply = Some(reply); break; } + if active.is_some() || preparing.is_some() || !self.attachments.is_empty() { Err(HostError::Busy) } else { self.retire_reply = Some(reply); break; } } }; let _ = reply.send(result); @@ -852,7 +933,7 @@ impl SessionDriver { } _ = tick.tick() => { self.expire_leases(); - if active.is_none() && self.attachments.is_empty() && (commands_closed || self.idle_since.elapsed() >= self.policy.idle_timeout) { break; } + if active.is_none() && preparing.is_none() && self.attachments.is_empty() && (commands_closed || self.idle_since.elapsed() >= self.policy.idle_timeout) { break; } } } } @@ -862,6 +943,10 @@ impl SessionDriver { cancellation_acknowledged: false, session_id: session_id.0.to_string(), initialization, + configuration: configuration + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), }) } fn expire_leases(&mut self) { @@ -880,7 +965,17 @@ async fn prompt_rpc( forced: CancellationToken, capture: SharedCapture, limits: ClientLimits, + configuration: Arc>, + callback_epoch: CancellationToken, + services: super::services::CallbackSender, ) -> Result { + // The submitted marker may finish persisting after cancellation. This + // check runs again when the active future is first polled, immediately + // before transport submission. + if cancellation.is_cancelled() || forced.is_cancelled() { + callback_epoch.cancel(); + return Err(ClientError::CancelledBeforePrompt); + } let request = UntypedMessage::new( "session/prompt", PromptRequest::new( @@ -889,13 +984,29 @@ async fn prompt_rpc( ), ) .map_err(|_| ClientError::Protocol { submitted: false })?; - let response = connection.send_request(request).block_task(); + let (response_tx, response_rx) = oneshot::channel(); + connection + .send_request(request) + .on_receiving_result(async move |result| { + // Revoke callback authority in ordered response dispatch, before an + // adjacent late callback can run. This does not cancel the prompt token. + callback_epoch.cancel(); + let _ = response_tx.send(result); + Ok(()) + }) + .map_err(|_| ClientError::Protocol { submitted: true })?; + let response = async { + response_rx + .await + .map_err(|_| agent_client_protocol::Error::internal_error())? + }; tokio::pin!(response); let mut cancellation_requested = false; let result = tokio::select! { result = &mut response => result, _ = async { tokio::select! { _ = cancellation.cancelled() => {}, _ = forced.cancelled() => {} } } => { cancellation_requested = true; + forced.cancel(); connection.send_notification(CancelNotification::new(session_id.clone())).map_err(|_| ClientError::Protocol { submitted: true })?; tokio::time::timeout(limits.cancel_timeout, &mut response).await.map_err(|_| ClientError::CancelTimeout)? }, @@ -919,11 +1030,16 @@ async fn prompt_rpc( { return Err(ClientError::Protocol { submitted: true }); } + services.end_turn(limits.setup_timeout).await?; Ok(TurnReport { cancellation_acknowledged: cancellation_requested && stop_reason == "cancelled", cancellation_requested, stop_reason, session_id: session_id.0.to_string(), initialization, + configuration: configuration + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), }) } diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs index fa2681e13..a0422392e 100644 --- a/crates/opensymphony-acp/src/lib.rs +++ b/crates/opensymphony-acp/src/lib.rs @@ -42,10 +42,16 @@ use tokio_util::{ }; use tracing::instrument::WithSubscriber; +mod atomic_file; mod durable; mod host; +mod services; +mod session_config; pub use host::*; - +pub use services::HostServices; +pub use session_config::SessionConfiguration; +#[cfg(windows)] +mod windows_path; #[cfg(windows)] mod windows_process; @@ -69,9 +75,10 @@ pub struct LaunchContext { pub issue_workspace: PathBuf, pub environment: BTreeMap, pub excluded_environment: BTreeSet, + pub services: HostServices, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct ClientLimits { pub frame_bytes: usize, pub queued_frames: usize, @@ -85,6 +92,11 @@ pub struct ClientLimits { /// Cumulative serialized bytes of retained, redacted SourceFrame values. pub evidence_bytes: usize, pub stderr_bytes: usize, + pub file_bytes: usize, + pub terminal_output_bytes: usize, + pub terminal_count: usize, + pub pending_callbacks: usize, + pub callback_timeout: Duration, pub setup_timeout: Duration, pub prompt_timeout: Duration, pub cancel_timeout: Duration, @@ -102,6 +114,11 @@ impl Default for ClientLimits { evidence_frames: 256, evidence_bytes: 1024 * 1024, stderr_bytes: 16 * 1024, + file_bytes: 256 * 1024, + terminal_output_bytes: 64 * 1024, + terminal_count: 16, + pending_callbacks: 64, + callback_timeout: Duration::from_secs(300), setup_timeout: Duration::from_secs(30), prompt_timeout: Duration::from_secs(300), cancel_timeout: Duration::from_secs(10), @@ -185,6 +202,7 @@ pub struct TurnReport { pub cancellation_acknowledged: bool, pub session_id: String, pub initialization: InitializeResponse, + pub configuration: SessionConfiguration, } // Negotiation metadata and opaque peer identifiers are operational values, not @@ -220,12 +238,21 @@ pub struct RunResult { /// Retaining the SDK's exact encoding also rejects unreserved SDK-generated responses. #[derive(Default)] struct CallbackOutput { - frames: VecDeque, + frames: VecDeque<(String, bool)>, bytes: usize, } impl CallbackOutput { fn admit(&mut self, frame: String, limits: &ClientLimits) -> bool { + self.admit_with_opaque_payload(frame, limits, false) + } + + fn admit_with_opaque_payload( + &mut self, + frame: String, + limits: &ClientLimits, + opaque_payload: bool, + ) -> bool { let bytes = frame.len() + 1; // LinesCodec adds LF. if frame.len() > limits.frame_bytes || self.frames.len() >= limits.callback_frames @@ -234,18 +261,18 @@ impl CallbackOutput { return false; } self.bytes += bytes; - self.frames.push_back(frame); + self.frames.push_back((frame, opaque_payload)); true } fn matches_front(&self, frame: &str) -> bool { self.frames .front() - .is_some_and(|expected| expected == frame) + .is_some_and(|(expected, _)| expected == frame) } fn flushed(&mut self) { - if let Some(frame) = self.frames.pop_front() { + if let Some((frame, _)) = self.frames.pop_front() { self.bytes -= frame.len() + 1; } } @@ -310,6 +337,17 @@ impl SecretRedactor { self.matcher.is_match(text) } + fn contains_in_json(&self, value: &Value) -> bool { + match value { + Value::String(value) => self.contains_secret(value), + Value::Array(values) => values.iter().any(|value| self.contains_in_json(value)), + Value::Object(values) => values + .iter() + .any(|(key, value)| self.contains_secret(key) || self.contains_in_json(value)), + _ => false, + } + } + fn redact(&self, text: &str) -> String { let mut result = String::with_capacity(text.len()); self.matcher @@ -373,6 +411,35 @@ impl Capture { } fn record(&mut self, direction: &str, mut payload: Value) { self.sequence += 1; + if payload.get("method").and_then(Value::as_str) == Some("fs/write_text_file") + && let Some(content) = payload.pointer_mut("/params/content") + { + *content = json!("[redacted]"); + } + if let Some(servers) = payload + .pointer_mut("/params/mcpServers") + .and_then(Value::as_array_mut) + { + for server in servers { + // Stdio argv can carry opaque credentials through generic flags + // such as --header. Capture only the attachment shape, never its + // argument values; the wire request remains unchanged. + if let Some(args) = server.get_mut("args").and_then(Value::as_array_mut) { + for arg in args { + *arg = json!("[redacted]"); + } + } + for field in ["env", "headers"] { + if let Some(entries) = server.get_mut(field).and_then(Value::as_array_mut) { + for entry in entries { + if let Some(value) = entry.get_mut("value") { + *value = json!("[redacted]"); + } + } + } + } + } + } if let Some(publisher) = &self.publisher { let mut source = payload.clone(); self.redact(&mut source, false); @@ -469,11 +536,16 @@ fn validate_launch( || limits.evidence_frames > 4096 || limits.evidence_bytes > 16 * 1024 * 1024 || limits.stderr_bytes > 1024 * 1024 + || limits.file_bytes > 16 * 1024 * 1024 + || limits.terminal_output_bytes > 1024 * 1024 + || !(1..=128).contains(&limits.terminal_count) + || !(1..=128).contains(&limits.pending_callbacks) || [ limits.setup_timeout, limits.prompt_timeout, limits.cancel_timeout, limits.reap_timeout, + limits.callback_timeout, ] .iter() .any(Duration::is_zero) @@ -569,6 +641,120 @@ fn validate_launch( "invalid launch environment".into(), )); } + if context.services.mcp_servers.len() > 32 + || serde_json::to_vec(&context.services.mcp_servers) + .map_or(true, |v| v.len() > limits.frame_bytes / 2) + { + return Err(ClientError::InvalidConfiguration( + "scoped MCP configuration exceeds limits".into(), + )); + } + let excluded_values = SecretRedactor::new( + context + .environment + .iter() + .filter(|(name, value)| excluded(name) && !value.is_empty()) + .map(|(_, value)| value.clone()) + .collect(), + )?; + for server in &context.services.mcp_servers { + use agent_client_protocol::schema::v1::McpServer; + if excluded_values.contains_in_json( + &serde_json::to_value(server) + .map_err(|_| ClientError::InvalidConfiguration("invalid MCP attachment".into()))?, + ) { + return Err(ClientError::InvalidConfiguration( + "MCP attachments cannot expose excluded checkout credentials".into(), + )); + } + match server { + McpServer::Http(server) => { + validate_mcp_url(&server.url)?; + secrets.extend( + server + .headers + .iter() + .filter(|h| { + runtime_field_is_sensitive(&h.name) + || h.name.eq_ignore_ascii_case("cookie") + }) + .map(|h| h.value.clone()) + .filter(|v| !v.is_empty()), + ); + } + McpServer::Sse(server) => { + validate_mcp_url(&server.url)?; + secrets.extend( + server + .headers + .iter() + .filter(|h| { + runtime_field_is_sensitive(&h.name) + || h.name.eq_ignore_ascii_case("cookie") + }) + .map(|h| h.value.clone()) + .filter(|v| !v.is_empty()), + ); + } + McpServer::Stdio(server) => { + if server.env.iter().any(|v| excluded(&v.name)) { + return Err(ClientError::InvalidConfiguration( + "MCP environment cannot expose excluded checkout credentials".into(), + )); + } + // Argument values are separate JSON strings, so generic text + // redaction cannot associate a token with the preceding flag. + let header_argument = + |flag: &str| flag == "-H" || flag.eq_ignore_ascii_case("--header"); + let sensitive_argument = |flag: &str| { + runtime_field_is_sensitive(flag) + || flag.eq_ignore_ascii_case("--oauth2-bearer") + || header_argument(flag) + }; + for (index, argument) in server.args.iter().enumerate() { + let (secret, header) = match argument.split_once('=') { + Some((flag, value)) if sensitive_argument(flag) => { + (Some(value), header_argument(flag)) + } + _ if index > 0 && sensitive_argument(&server.args[index - 1]) => ( + Some(argument.as_str()), + header_argument(&server.args[index - 1]), + ), + _ => (None, false), + }; + if let Some(value) = secret.filter(|value| !value.is_empty()) { + secrets.push(value.to_owned()); + if header && let Some((_, header_value)) = value.split_once(':') { + let header_value = header_value.trim(); + if !header_value.is_empty() { + secrets.push(header_value.to_owned()); + } + } + } + } + secrets.extend( + server + .env + .iter() + .filter(|e| runtime_field_is_sensitive(&e.name)) + .map(|e| e.value.clone()) + .filter(|v| !v.is_empty()), + ); + } + _ => { + return Err(ClientError::InvalidConfiguration( + "unsupported MCP transport".into(), + )); + } + } + } + secrets.extend( + secrets + .clone() + .into_iter() + .filter_map(|value| value.strip_prefix("Bearer ").map(str::to_owned)) + .filter(|value| !value.is_empty()), + ); let secrets = SecretRedactor::new(secrets)?; if secrets.contains_secret(&profile.command) || profile.args.iter().any(|arg| secrets.contains_secret(arg)) @@ -584,6 +770,24 @@ fn validate_launch( }) } +// Credentials belong in resolved headers, which are registered with the redactor. +// Reject credential-bearing URLs before either a process or source observer exists. +fn validate_mcp_url(value: &str) -> Result<(), ClientError> { + let invalid = + || ClientError::InvalidConfiguration("invalid or credential-bearing MCP URL".into()); + let url = url::Url::parse(value).map_err(|_| invalid())?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || url.query().is_some() + { + return Err(invalid()); + } + Ok(()) +} + /// Run a fresh session through setup, one prompt, cancellation, and supervised teardown. /// The bounded update channel is optional. A full or closed supplied channel fails the run /// visibly, so callers must drain it concurrently. The launch environment is explicit: @@ -638,13 +842,39 @@ async fn run_connection( let mut command = Command::new(&profile.command); command .args(&profile.args) - .current_dir(&cwd) .env_clear() - .envs(environment) + .envs(&environment) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); + #[cfg(not(unix))] + command.current_dir(&cwd); + let queued = Arc::new(Mutex::new(QueuedInput::default())); + let callback_output = Arc::new(Mutex::new(CallbackOutput::default())); + let resource_failure = Arc::new(AtomicBool::new(false)); + let submitted = Arc::new(AtomicBool::new(false)); + let fatal = CancellationToken::new(); + let service_shutdown = CancellationToken::new(); + let _service_guard = service_shutdown.clone().drop_guard(); + let initial_callback_epoch = CancellationToken::new(); + initial_callback_epoch.cancel(); + let (service_sender, service_actor) = services::Services::new( + cwd.clone(), + environment, + context.services.clone(), + limits.clone(), + callback_output.clone(), + resource_failure.clone(), + fatal.clone(), + initial_callback_epoch, + service_shutdown.clone(), + ) + .map_err(|_| ClientError::InvalidWorkspace)?; + #[cfg(unix)] + service_actor + .pin_child_cwd(&mut command) + .map_err(|_| ClientError::InvalidWorkspace)?; #[cfg(not(windows))] configure_process_group(&mut command); #[cfg(not(windows))] @@ -662,11 +892,9 @@ async fn run_connection( let stdin = child.stdin.take().ok_or(ClientError::Teardown)?; let stdout = child.stdout.take().ok_or(ClientError::Teardown)?; let stderr = child.stderr.take().ok_or(ClientError::Teardown)?; - let queued = Arc::new(Mutex::new(QueuedInput::default())); - let callback_output = Arc::new(Mutex::new(CallbackOutput::default())); - let resource_failure = Arc::new(AtomicBool::new(false)); - let submitted = Arc::new(AtomicBool::new(false)); - let fatal = CancellationToken::new(); + let service_run = service_actor.run(); + tokio::pin!(service_run); + let configuration = Arc::new(Mutex::new(SessionConfiguration::default())); let input = FramedRead::new(stdout, LinesCodec::new_with_max_length(limits.frame_bytes)); let incoming = input.map({ let queued = queued.clone(); @@ -712,17 +940,21 @@ async fn run_connection( resource_failure.store(true, Ordering::Release); return Err(io_failure()); } - let value: Value = serde_json::from_str(&line).map_err(|_| io_failure())?; + let mut value: Value = serde_json::from_str(&line).map_err(|_| io_failure())?; let is_response = value.get("method").is_none(); - if is_response - && !callback_output - .lock() - .unwrap_or_else(|e| e.into_inner()) - .matches_front(&line) - { - // Invalid incoming RPC envelopes can provoke SDK-generated errors. - // They must not bypass callback admission or release another charge. - return Err(io_failure()); + if is_response { + let output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if !output.matches_front(&line) { + // Unreserved SDK errors cannot bypass callback admission. + return Err(io_failure()); + } + if output.frames.front().is_some_and(|(_, opaque)| *opaque) { + for field in ["/result/content", "/result/output"] { + if let Some(content) = value.pointer_mut(field) { + *content = json!("[redacted]"); + } + } + } } if value.get("method").and_then(Value::as_str) == Some("session/prompt") { submitted.store(true, Ordering::Release); @@ -748,6 +980,8 @@ async fn run_connection( let resource_failure = resource_failure.clone(); let fatal = fatal.clone(); let redactor = capture_state.clone(); + let configuration = configuration.clone(); + let service_sender = service_sender.clone(); async move |message: Dispatch, _cx| { queued .lock() @@ -755,6 +989,24 @@ async fn run_connection( .dispatched(); match message { Dispatch::Request(request, responder) => { + let service_method = request.method.starts_with("fs/") + || request.method.starts_with("terminal/"); + let session = request.params.get("sessionId").and_then(Value::as_str); + if service_method + && session.is_some() + && active_session + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_deref() + == session + { + return service_sender + .enqueue(request.method, request.params, responder) + .inspect_err(|_| { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + }); + } let response = if request.method == "session/request_permission" { match serde_json::from_value::(request.params) { @@ -780,11 +1032,9 @@ async fn run_connection( responder.id().clone(), response.clone(), ))?; - if !callback_output - .lock() - .unwrap_or_else(|e| e.into_inner()) - .admit(frame, &limits) - { + let mut callback_output = + callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if !callback_output.admit(frame, &limits) { resource_failure.store(true, Ordering::Release); fatal.cancel(); // Individual-request responders send nothing on drop. Batches @@ -816,6 +1066,11 @@ async fn run_connection( fatal.cancel(); return Err(agent_client_protocol::Error::invalid_params()); } + configuration + .lock() + .unwrap_or_else(|e| e.into_inner()) + .update(update) + .inspect_err(|_| fatal.cancel())?; if let Some(tx) = &updates { let mut update = update.clone(); redactor @@ -844,9 +1099,10 @@ async fn run_connection( let mut phase_error = None; let run = client .connect_with(Lines::new(outgoing, incoming), async |connection| { + let one_turn_epoch = cancellation.child_token(); let setup = async { let initialization = connection - .send_request(InitializeRequest::new(ProtocolVersion::V1)) + .send_request(InitializeRequest::new(ProtocolVersion::V1).client_capabilities(context.services.capabilities())) .block_task() .await.inspect_err(|error| { phase_error = rpc_failure("initialize", error, &capture_state, false); @@ -882,6 +1138,10 @@ async fn run_connection( phase_error = rpc_failure("authenticate", error, &capture_state, false); })?; } + if let Err(error) = session_config::validate_transports(&context.services.mcp_servers, &initialization.agent_capabilities) { + phase_error = Some(error); + return Err(agent_client_protocol::Error::internal_error()); + } let restoration = match driver.as_deref_mut() { Some(driver) => driver.restoration(&initialization).map_err(|error| { phase_error = Some(ClientError::Setup(error.to_string())); @@ -895,8 +1155,16 @@ async fn run_connection( let restored = if replay { let (loaded_tx, loaded_rx) = oneshot::channel(); let publisher = driver.as_ref().map(|driver| driver.publisher.clone()); - connection.send_request(LoadSessionRequest::new(session_id.clone(), cwd.clone())) + let session_configuration = configuration.clone(); + let restoration_binding = active_session.clone(); + connection.send_request(LoadSessionRequest::new(session_id.clone(), cwd.clone()).mcp_servers(context.services.mcp_servers.clone())) .on_receiving_result(async move |result| { + if let Ok(session) = &result { + session_configuration.lock().unwrap_or_else(|e| e.into_inner()).initial(&serde_json::to_value(session)?); + } else { + // Revoke in ordered dispatch before an adjacent old-session callback. + *restoration_binding.lock().unwrap_or_else(|e| e.into_inner()) = None; + } if let Some(publisher) = publisher { publisher.end_replay(); } @@ -905,12 +1173,31 @@ async fn run_connection( })?; loaded_rx.await.map_err(|_| agent_client_protocol::Error::internal_error())?.map(|_| ()) } else { - connection.send_request(ResumeSessionRequest::new(session_id.clone(), cwd.clone())) - .block_task().await.map(|_| ()) + let (resumed_tx, resumed_rx) = oneshot::channel(); + let session_configuration = configuration.clone(); + let restoration_binding = active_session.clone(); + connection.send_request(ResumeSessionRequest::new(session_id.clone(), cwd.clone()).mcp_servers(context.services.mcp_servers.clone())) + .on_receiving_result(async move |result| { + if let Ok(session) = &result { + session_configuration.lock().unwrap_or_else(|e| e.into_inner()).initial(&serde_json::to_value(session)?); + } else { + *restoration_binding.lock().unwrap_or_else(|e| e.into_inner()) = None; + } + let _ = resumed_tx.send(result); + Ok(()) + })?; + resumed_rx.await.map_err(|_| agent_client_protocol::Error::internal_error())?.map(|_| ()) }; match restored { Ok(()) => Some(session_id), - Err(error) if driver.as_deref_mut().is_some_and(|driver| driver.reset_missing_session(&error)) => None, + Err(error) if driver.as_deref_mut().is_some_and(|driver| driver.reset_missing_session(&error)) => { + // Retire accepted restoration work and handles before binding a fresh session. + if let Err(error) = service_sender.end_turn(limits.setup_timeout).await { + phase_error = Some(error); + return Err(agent_client_protocol::Error::internal_error()); + } + None + }, Err(error) => { phase_error = rpc_failure(if replay { "session/load" } else { "session/resume" }, &error, &capture_state, false); return Err(error); @@ -920,12 +1207,14 @@ async fn run_connection( let session_id = if let Some(session_id) = restored_id { session_id.into() } else { let (session_tx, session_rx) = oneshot::channel(); let active_session = active_session.clone(); + let session_configuration = configuration.clone(); // Bind in ordered response dispatch before any adjacent session update. - connection.send_request(NewSessionRequest::new(cwd.clone())) + connection.send_request(NewSessionRequest::new(cwd.clone()).mcp_servers(context.services.mcp_servers.clone())) .on_receiving_result(async move |result| { if let Ok(session) = &result { *active_session.lock().unwrap_or_else(|e| e.into_inner()) = Some(session.session_id.0.to_string()); + session_configuration.lock().unwrap_or_else(|e| e.into_inner()).initial(&serde_json::to_value(session)?); } let _ = session_tx.send(result); Ok(()) @@ -936,7 +1225,22 @@ async fn run_connection( phase_error = rpc_failure("session/new", error, &capture_state, false); })?.session_id }; + if let Err(error) = session_config::apply(&connection, profile, session_id.0.as_ref(), &configuration, &capture_state).await { + phase_error = Some(error); + return Err(agent_client_protocol::Error::internal_error()); + } + if driver.is_none() + && let Err(error) = service_sender.begin_turn(one_turn_epoch.clone(), limits.setup_timeout).await { + phase_error = Some(error); + return Err(agent_client_protocol::Error::internal_error()); + } + if driver.is_some() + && let Err(error) = service_sender.end_turn(limits.setup_timeout).await { + phase_error = Some(error); + return Err(agent_client_protocol::Error::internal_error()); + } Ok((initialization, session_id)) + }; let setup_result = tokio::select! { _ = cancellation.cancelled() => { @@ -953,7 +1257,7 @@ async fn run_connection( } }; if let Some(driver) = driver.as_deref_mut() { - return driver.drive(connection, initialization, session_id, &capture_state, &limits).await + return driver.drive(connection, initialization, session_id, &capture_state, &limits, &service_sender, &configuration, profile).await .map_err(|error| { phase_error = Some(error); agent_client_protocol::Error::internal_error() @@ -968,7 +1272,17 @@ async fn run_connection( ); // Raw SDK response decoding preserves future stop reasons; request serialization stays typed. let request = UntypedMessage::new("session/prompt", request)?; - let response = connection.send_request(request).block_task(); + let (response_tx, response_rx) = oneshot::channel(); + connection.send_request(request).on_receiving_result(async move |result| { + // Match retained prompts: revoke callbacks before the next + // inbound frame is dispatched, including adjacent late writes. + one_turn_epoch.cancel(); + let _ = response_tx.send(result); + Ok(()) + })?; + let response = async { + response_rx.await.map_err(|_| agent_client_protocol::Error::internal_error())? + }; tokio::pin!(response); let mut cancellation_requested = false; let result = tokio::select! { @@ -993,19 +1307,29 @@ async fn run_connection( })?; let stop_reason = result.get("stopReason").and_then(Value::as_str) .ok_or_else(agent_client_protocol::Error::invalid_params)?.to_owned(); + if let Err(error) = service_sender.end_turn(limits.setup_timeout).await { + phase_error = Some(error); + return Err(agent_client_protocol::Error::internal_error()); + } Ok(TurnReport { cancellation_acknowledged: cancellation_requested && stop_reason == "cancelled", cancellation_requested, stop_reason, session_id: session_id.0.to_string(), initialization, + configuration: configuration.lock().unwrap_or_else(|e|e.into_inner()).clone(), }) }) .with_subscriber(tracing::subscriber::NoSubscriber::default()); let stderr_drain = drain_stderr(stderr, limits.stderr_bytes); tokio::pin!(stderr_drain); let mut stderr_result = None; + let mut service_completed = None; let result = { tokio::pin!(run); loop { tokio::select! { + completed = &mut service_run => { + service_completed = Some(completed); + break Err(agent_client_protocol::Error::internal_error()); + }, _ = fatal.cancelled() => break Err(agent_client_protocol::Error::internal_error()), result = &mut run => break result, stderr = &mut stderr_drain, if stderr_result.is_none() => { @@ -1045,6 +1369,13 @@ async fn run_connection( submitted: submitted.load(Ordering::Acquire), }); } + service_shutdown.cancel(); + if !match service_completed { + Some(completed) => completed, + None => service_run.await, + } { + outcome = Err(ClientError::Teardown); + } let reap_deadline = tokio::time::Instant::now() + limits.reap_timeout; #[cfg(windows)] let stopped_result = child.start_kill(); diff --git a/crates/opensymphony-acp/src/services.rs b/crates/opensymphony-acp/src/services.rs new file mode 100644 index 000000000..2cc5b5af4 --- /dev/null +++ b/crates/opensymphony-acp/src/services.rs @@ -0,0 +1,1251 @@ +//! Connection-owned client facilities. Callback containment is not a process sandbox. +use super::{CallbackOutput, ClientError, ClientLimits}; +#[cfg(not(windows))] +use crate::opensymphony_workspace::{configure_process_group, terminate_process_tree}; +use crate::opensymphony_workspace::{ + environment_variable_names_equal, has_environment_name_collision, resolve_path_within_root, +}; +use agent_client_protocol::{ + Error, Responder, + schema::v1::{ClientCapabilities, FileSystemCapabilities, McpServer}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{ + collections::BTreeMap, + path::{Component, Path, PathBuf}, + process::Stdio, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + process::Command, + sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch}, + task::JoinSet, +}; +use tokio_util::sync::CancellationToken; + +/// Immutable host policy and resolved, scoped MCP grants. Never populated from IDE attachment. +#[derive(Default, Clone)] +pub struct HostServices { + pub read_files: bool, + pub write_files: bool, + pub terminals: bool, + pub mcp_servers: Vec, +} +impl HostServices { + pub(super) fn capabilities(&self) -> ClientCapabilities { + ClientCapabilities::default() + .fs(FileSystemCapabilities::default() + .read_text_file(self.read_files) + .write_text_file(self.write_files)) + .terminal(self.terminals) + } +} + +pub(super) struct Callback { + pub method: String, + pub params: Value, + pub responder: Responder, + pub permit: OwnedSemaphorePermit, + pub bytes: OwnedSemaphorePermit, +} +enum ServiceCommand { + Callback(Box), + BeginTurn(CancellationToken, oneshot::Sender>), + EndTurn(oneshot::Sender>), +} +#[derive(Clone)] +pub(super) struct CallbackSender { + tx: mpsc::Sender, + permits: Arc, + bytes: Arc, +} +impl CallbackSender { + /// Quiesce callbacks without installing another active epoch. + pub async fn end_turn(&self, timeout: Duration) -> Result<(), ClientError> { + let (tx, rx) = oneshot::channel(); + self.tx + .try_send(ServiceCommand::EndTurn(tx)) + .map_err(|_| ClientError::Teardown)?; + tokio::time::timeout(timeout, rx) + .await + .map_err(|_| ClientError::Teardown)? + .map_err(|_| ClientError::Teardown)? + .map_err(|_| ClientError::Teardown) + } + /// Retire the previous callback epoch before a retained owner submits another prompt. + pub async fn begin_turn( + &self, + cancellation: CancellationToken, + timeout: Duration, + ) -> Result<(), ClientError> { + let (tx, rx) = oneshot::channel(); + self.tx + .try_send(ServiceCommand::BeginTurn(cancellation.clone(), tx)) + .map_err(|_| ClientError::Teardown)?; + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(ClientError::CancelledBeforePrompt), + result = tokio::time::timeout(timeout, rx) => { + result.map_err(|_| ClientError::SetupTimeout)? + .map_err(|_| ClientError::Teardown)? + .map_err(|_| ClientError::Teardown) + } + } + } + pub fn enqueue( + &self, + method: String, + params: Value, + responder: Responder, + ) -> Result<(), Error> { + let permit = self + .permits + .clone() + .try_acquire_owned() + .map_err(|_| Error::internal_error())?; + let size = serde_json::to_vec(¶ms).map_err(Error::from)?.len() + + serde_json::to_vec(responder.id()) + .map_err(Error::from)? + .len() + + method.len(); + let bytes = self + .bytes + .clone() + .try_acquire_many_owned(u32::try_from(size).map_err(|_| Error::internal_error())?) + .map_err(|_| Error::internal_error())?; + self.tx + .try_send(ServiceCommand::Callback(Box::new(Callback { + method, + params, + responder, + permit, + bytes, + }))) + .map_err(|_| Error::internal_error()) + } +} + +pub(super) struct Services { + root: PathBuf, + #[cfg(unix)] + root_directory: std::os::fd::OwnedFd, + environment: BTreeMap, + policy: HostServices, + limits: ClientLimits, + rx: mpsc::Receiver, + terminals: BTreeMap, + processes: JoinSet, + processes_ok: bool, + replies: JoinSet<()>, + output: Arc>, + resource_failure: Arc, + fatal: CancellationToken, + cancellation: CancellationToken, + shutdown: CancellationToken, +} +impl Services { + #[cfg(unix)] + pub fn pin_child_cwd(&self, command: &mut Command) -> std::io::Result<()> { + pin_terminal_cwd(command, &self.root, &self.root_directory, &self.root) + } + + #[allow(clippy::too_many_arguments)] + pub fn new( + root: PathBuf, + environment: BTreeMap, + policy: HostServices, + limits: ClientLimits, + output: Arc>, + resource_failure: Arc, + fatal: CancellationToken, + cancellation: CancellationToken, + shutdown: CancellationToken, + ) -> Result<(CallbackSender, Self), std::io::Error> { + #[cfg(unix)] + let root_directory = rustix::fs::open( + &root, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + )?; + let (tx, rx) = mpsc::channel(limits.pending_callbacks); + let sender = CallbackSender { + tx, + permits: Arc::new(Semaphore::new(limits.pending_callbacks)), + bytes: Arc::new(Semaphore::new(limits.queued_bytes)), + }; + Ok(( + sender, + Self { + root, + #[cfg(unix)] + root_directory, + environment, + policy, + limits, + rx, + terminals: BTreeMap::new(), + processes: JoinSet::new(), + processes_ok: true, + replies: JoinSet::new(), + output, + resource_failure, + fatal, + cancellation, + shutdown, + }, + )) + } + + pub async fn run(mut self) -> bool { + loop { + tokio::select! { + biased; + _ = self.shutdown.cancelled() => break, + Some(result) = self.processes.join_next(), if !self.processes.is_empty() => { self.processes_ok &= matches!(result, Ok(true)); }, + Some(_) = self.replies.join_next(), if !self.replies.is_empty() => {}, + request = self.rx.recv() => { + let request = match request { + None => break, + Some(ServiceCommand::Callback(request)) => *request, + Some(ServiceCommand::BeginTurn(cancellation, reply)) => { + let result = self.retire_turn().await.map(|()| self.cancellation = cancellation.child_token()); + if result.is_err() { self.fatal.cancel(); } + let _ = reply.send(result); + continue; + } + Some(ServiceCommand::EndTurn(reply)) => { + let result = self.retire_turn().await; + if result.is_err() { self.fatal.cancel(); } + let _ = reply.send(result); + continue; + } + }; + let shutdown = self.shutdown.clone(); + let result = tokio::select! { + biased; + _ = shutdown.cancelled() => break, + result = self.handle_callback(&request.method, request.params) => result, + }; + let output = self.output.clone(); + let resource_failure = self.resource_failure.clone(); + let fatal = self.fatal.clone(); + let cancellation = self.cancellation.clone(); + let limits = self.limits.clone(); + self.replies.spawn(async move { + let _permit = request.permit; + let _bytes = request.bytes; + let result = match result { + Ok(Reply::Ready(value)) => Ok(value), + Ok(Reply::Wait(mut terminal, release)) => tokio::select! { + result = tokio::time::timeout(limits.callback_timeout, terminal.wait_for(|state| state.exit.is_some())) => { + match result { + Ok(Ok(state)) => state.exit.clone().unwrap_or_else(|| Err(Error::internal_error())).map(|exit| if release { json!({}) } else { exit }), + _ => Err(Error::new(-32603, "terminal wait expired")), + } + }, + _ = cancellation.cancelled() => Err(Error::new(-32603, "session cancelled")), + }, + Err(error) => Err(error), + }; + let result = bounded_response(result, request.responder.id(), &limits); + let frame = serde_json::to_string(&agent_client_protocol::RawJsonRpcMessage::response(request.responder.id().clone(), result.clone())); + // Reservation and SDK enqueue are one critical section: asynchronous + // waits may finish together, but the writer must see matching FIFO order. + let mut output = output.lock().unwrap_or_else(|e| e.into_inner()); + if frame.is_ok_and(|frame| output.admit_with_opaque_payload(frame, &limits, matches!(request.method.as_str(), "fs/read_text_file" | "terminal/output"))) { + if request.responder.respond_with_result(result).is_err() { fatal.cancel(); } + } else { + resource_failure.store(true, std::sync::atomic::Ordering::Release); + fatal.cancel(); + } + }); + } + } + } + self.rx.close(); + for terminal in self.terminals.values() { + terminal.kill.cancel(); + } + self.replies.abort_all(); + // Wait for every owned process task to kill its tree and reap its child. + let drained = + tokio::time::timeout(self.limits.reap_timeout + self.limits.reap_timeout, async { + while let Some(result) = self.processes.join_next().await { + self.processes_ok &= matches!(result, Ok(true)); + } + }) + .await + .is_ok(); + drained + && self.processes_ok + && self + .terminals + .values() + .all(|t| t.state.borrow().exit.as_ref().is_some_and(Result::is_ok)) + } + + async fn retire_turn(&mut self) -> Result<(), Error> { + if !self.processes_ok { + return Err(Error::internal_error()); + } + self.cancellation.cancel(); + for terminal in self.terminals.values() { + terminal.kill.cancel(); + } + tokio::time::timeout(self.limits.reap_timeout + self.limits.reap_timeout, async { + while let Some(result) = self.processes.join_next().await { + if !matches!(result, Ok(true)) { + return Err(Error::internal_error()); + } + } + while let Some(result) = self.replies.join_next().await { + if result.is_err() { + return Err(Error::internal_error()); + } + } + if self.terminals.values().any(|terminal| { + !terminal + .state + .borrow() + .exit + .as_ref() + .is_some_and(Result::is_ok) + }) { + return Err(Error::internal_error()); + } + self.terminals.clear(); + Ok(()) + }) + .await + .map_err(|_| Error::internal_error())? + } + + async fn handle_callback(&mut self, method: &str, params: Value) -> Result { + let cancellation = self.cancellation.clone(); + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(Error::new(-32603, "session cancelled")), + result = tokio::time::timeout(self.limits.callback_timeout, self.handle(method, params)) => + result.unwrap_or_else(|_| Err(Error::new(-32603, "callback deadline exceeded"))), + } + } + + async fn handle(&mut self, method: &str, params: Value) -> Result { + match method { + "fs/read_text_file" if self.policy.read_files => { + let request: ReadFile = + serde_json::from_value(params).map_err(|_| Error::invalid_params())?; + let path = self.path(&request.path, false).await?; + #[cfg(windows)] + let (mut file, _path_guards) = super::windows_path::open_file(&path, false) + .await + .map_err(io_error)?; + #[cfg(not(windows))] + let mut file = self.open_file(&path, false).await?; + if !file.metadata().await.map_err(io_error)?.is_file() { + return Err(Error::invalid_params()); + } + let mut bytes = Vec::new(); + (&mut file) + .take(self.limits.file_bytes as u64 + 1) + .read_to_end(&mut bytes) + .await + .map_err(io_error)?; + if bytes.len() > self.limits.file_bytes || request.line == Some(0) { + return Err(Error::invalid_params()); + } + let text = String::from_utf8(bytes).map_err(|_| Error::invalid_params())?; + let content = if request.line.is_none() && request.limit.is_none() { + text + } else { + text.split_inclusive('\n') + .skip(request.line.unwrap_or(1) as usize - 1) + .take(request.limit.map_or(usize::MAX, |n| n as usize)) + .collect() + }; + Ok(Reply::Ready(json!({"content":content}))) + } + "fs/write_text_file" if self.policy.write_files => { + let request: WriteFile = + serde_json::from_value(params).map_err(|_| Error::invalid_params())?; + if request.content.len() > self.limits.file_bytes { + return Err(Error::invalid_params()); + } + let path = self.path(&request.path, true).await?; + // Check the complete path before creating parents; nonexistent leaves are + // validated against the nearest existing ancestor, including dangling links. + #[cfg(unix)] + let stage = super::atomic_file::AtomicFile::new_pinned( + &self.root, + &path, + &self.root_directory, + ); + #[cfg(not(unix))] + let stage = super::atomic_file::AtomicFile::new(&self.root, &path); + let mut stage = stage.await.map_err(io_error)?; + stage + .file + .write_all(request.content.as_bytes()) + .await + .map_err(io_error)?; + stage.file.flush().await.map_err(io_error)?; + if self.cancellation.is_cancelled() { + return Err(Error::new(-32603, "session cancelled")); + } + stage.commit().map_err(io_error)?; + Ok(Reply::Ready(json!({}))) + } + "terminal/create" if self.policy.terminals => { + let request: CreateTerminal = + serde_json::from_value(params).map_err(|_| Error::invalid_params())?; + if self.terminals.len() >= self.limits.terminal_count + || self.processes.len() >= self.limits.terminal_count + || request.command.is_empty() + || request.command.len() > 8192 + || request.command.contains('\0') + || request.args.len() > 128 + || request + .args + .iter() + .any(|v| v.len() > 8192 || v.contains('\0')) + || request.env.len() > 128 + || has_environment_name_collision(request.env.iter().map(|v| v.name.as_str())) + { + return Err(Error::invalid_params()); + } + // Per-command overrides may only repeat host-owned values. This keeps + // scoped grants, credentials and executable search paths immutable. + if request.env.iter().any(|v| { + !self.environment.iter().any(|(name, value)| { + environment_variable_names_equal(name, &v.name) && value == &v.value + }) + }) { + return Err(Error::invalid_params()); + } + let cwd = self + .path(request.cwd.as_deref().unwrap_or(&self.root), false) + .await?; + if !tokio::fs::metadata(&cwd).await.map_err(io_error)?.is_dir() { + return Err(Error::invalid_params()); + } + let output_limit = request + .output_byte_limit + .unwrap_or(self.limits.terminal_output_bytes as u64); + if output_limit > self.limits.terminal_output_bytes as u64 { + return Err(Error::invalid_params()); + } + #[cfg(windows)] + let _cwd_guards = super::windows_path::pin_directory(&cwd, false) + .await + .map_err(io_error)?; + let mut command = Command::new(request.command); + command + .args(request.args) + .env_clear() + .envs(&self.environment) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(not(unix))] + command.current_dir(&cwd); + #[cfg(unix)] + pin_terminal_cwd(&mut command, &self.root, &self.root_directory, &cwd) + .map_err(io_error)?; + #[cfg(not(windows))] + configure_process_group(&mut command); + #[cfg(not(windows))] + let child = command.spawn().map_err(io_error)?; + #[cfg(windows)] + let child = + super::windows_process::WindowsChild::spawn(command).map_err(io_error)?; + let id = uuid::Uuid::new_v4().to_string(); + let kill = self.cancellation.child_token(); + let (tx, state) = watch::channel(TerminalState::default()); + self.processes.spawn(run_terminal( + #[cfg(unix)] + crate::opensymphony_workspace::ProcessGroupGuard::new(child.id()), + child, + output_limit as usize, + kill.clone(), + self.shutdown.clone(), + tx, + self.limits.reap_timeout, + )); + self.terminals.insert(id.clone(), Terminal { state, kill }); + Ok(Reply::Ready(json!({"terminalId":id}))) + } + "terminal/output" | "terminal/wait_for_exit" | "terminal/kill" | "terminal/release" + if self.policy.terminals => + { + let request: TerminalRequest = + serde_json::from_value(params).map_err(|_| Error::invalid_params())?; + let terminal = self + .terminals + .get(&request.terminal_id) + .ok_or_else(Error::invalid_params)?; + match method { + "terminal/output" => { + let state = terminal.state.borrow(); + if state.exit.as_ref().is_some_and(Result::is_err) { + return Err(Error::internal_error()); + } + Ok(Reply::Ready( + json!({"output":state.output,"truncated":state.truncated,"exitStatus":state.exit.as_ref().and_then(|v|v.as_ref().ok())}), + )) + } + "terminal/kill" => { + terminal.kill.cancel(); + Ok(Reply::Wait(terminal.state.clone(), true)) + } + "terminal/release" => { + let terminal = self + .terminals + .remove(&request.terminal_id) + .ok_or_else(Error::invalid_params)?; + terminal.kill.cancel(); + Ok(Reply::Wait(terminal.state, true)) + } + _ => Ok(Reply::Wait(terminal.state.clone(), false)), + } + } + _ => Err(Error::method_not_found()), + } + } + + #[cfg(not(windows))] + async fn open_file(&self, path: &Path, write: bool) -> Result { + #[cfg(unix)] + { + use rustix::fs::{FileType, Mode, OFlags, fstat, mkdirat, openat}; + let mut directory = openat( + &self.root_directory, + ".", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|e| io_error(e.into()))?; + let relative = path + .strip_prefix(&self.root) + .map_err(|_| Error::invalid_params())?; + let mut parts = relative.components().peekable(); + while let Some(part) = parts.next() { + if parts.peek().is_none() { + let flags = OFlags::NOFOLLOW + | OFlags::NONBLOCK + | OFlags::CLOEXEC + | if write { + OFlags::WRONLY | OFlags::CREATE + } else { + OFlags::RDONLY + }; + let file = openat( + &directory, + part.as_os_str(), + flags, + Mode::from_bits_truncate(0o600), + ) + .map_err(|e| io_error(e.into()))?; + if FileType::from_raw_mode( + fstat(&file).map_err(|e| io_error(e.into()))?.st_mode, + ) != FileType::RegularFile + { + return Err(Error::invalid_params()); + } + return Ok(tokio::fs::File::from_std(std::fs::File::from(file))); + } + if write { + match mkdirat( + &directory, + part.as_os_str(), + Mode::from_bits_truncate(0o700), + ) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(error) => return Err(io_error(error.into())), + } + } + directory = openat( + &directory, + part.as_os_str(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|e| io_error(e.into()))?; + } + Err(Error::invalid_params()) + } + #[cfg(not(unix))] + { + if write { + tokio::fs::create_dir_all(path.parent().ok_or_else(Error::invalid_params)?) + .await + .map_err(io_error)?; + } + self.path(path, write).await?; + if tokio::fs::metadata(path).await.is_ok_and(|m| !m.is_file()) { + return Err(Error::invalid_params()); + } + tokio::fs::OpenOptions::new() + .read(!write) + .write(write) + .create(write) + .open(path) + .await + .map_err(io_error) + } + } + + async fn path(&self, path: &Path, missing: bool) -> Result { + if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(Error::invalid_params()); + } + let path = + resolve_path_within_root(&self.root, path).map_err(|_| Error::invalid_params())?; + let relative = path + .strip_prefix(&self.root) + .map_err(|_| Error::invalid_params())?; + let mut current = self.root.clone(); + for part in relative.components() { + current.push(part); + match tokio::fs::symlink_metadata(¤t).await { + Ok(meta) if meta.file_type().is_symlink() => { + let canonical = tokio::fs::canonicalize(¤t).await.map_err(io_error)?; + if !canonical.starts_with(&self.root) { + return Err(Error::invalid_params()); + } + } + Ok(_) => {} + Err(error) if missing && error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(error)), + } + } + Ok(path) + } +} + +#[cfg(unix)] +fn pin_terminal_cwd( + command: &mut Command, + root: &Path, + root_directory: &std::os::fd::OwnedFd, + cwd: &Path, +) -> std::io::Result<()> { + use rustix::fs::{Mode, OFlags, openat}; + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut directory = openat(root_directory, ".", flags, Mode::empty())?; + let relative = cwd + .strip_prefix(root) + .map_err(|_| std::io::ErrorKind::InvalidInput)?; + for component in relative.components() { + if !matches!(component, Component::Normal(_)) { + return Err(std::io::ErrorKind::InvalidInput.into()); + } + directory = openat(&directory, component.as_os_str(), flags, Mode::empty())?; + } + set_child_directory(command, directory); + Ok(()) +} + +#[cfg(unix)] +#[allow(unsafe_code)] +fn set_child_directory(command: &mut Command, directory: std::os::fd::OwnedFd) { + // SAFETY: the closure only invokes async-signal-safe fchdir on the owned, + // already-contained directory. It allocates nothing, takes no locks and + // changes only the forked child's cwd. Command owns the descriptor through + // spawn; CLOEXEC closes the child copy after the cwd has been established. + unsafe { + command.pre_exec(move || rustix::process::fchdir(&directory).map_err(std::io::Error::from)); + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReadFile { + path: PathBuf, + line: Option, + limit: Option, +} +#[derive(Deserialize)] +struct WriteFile { + path: PathBuf, + content: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateTerminal { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: Vec, + cwd: Option, + output_byte_limit: Option, +} +#[derive(Deserialize)] +struct Env { + name: String, + value: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TerminalRequest { + terminal_id: String, +} +// Bound the complete encoded frame, including the peer's request ID and JSON +// escaping. An oversized file is a callback error; terminal tails are truncatable. +fn bounded_response( + mut result: Result, + id: &agent_client_protocol::schema::v1::RequestId, + limits: &ClientLimits, +) -> Result { + let budget = limits + .frame_bytes + .min(limits.callback_bytes.saturating_sub(1)); + let fits = |result: &Result| { + serde_json::to_vec(&agent_client_protocol::RawJsonRpcMessage::response( + id.clone(), + result.clone(), + )) + .is_ok_and(|frame| frame.len() <= budget) + }; + if fits(&result) { + return result; + } + if let Ok(value) = &mut result + && let Some(output) = value.get("output").and_then(Value::as_str) + { + let output = output.to_owned(); + value["output"] = json!(""); + value["truncated"] = json!(true); + let overhead = serde_json::to_vec(&agent_client_protocol::RawJsonRpcMessage::response( + id.clone(), + Ok(value.clone()), + )) + .map_or(budget, |frame| frame.len()); + // Any UTF-8 byte needs at most six JSON bytes (e.g. a NUL). + let keep = budget.saturating_sub(overhead) / 6; + let mut start = output.len().saturating_sub(keep); + while !output.is_char_boundary(start) { + start += 1; + } + value["output"] = json!(&output[start..]); + if fits(&result) { + return result; + } + } + Err(Error::new( + -32602, + "callback response exceeds configured byte budget", + )) +} + +fn io_error(error: std::io::Error) -> Error { + Error::new( + -32603, + format!("host facility I/O failed ({:?})", error.kind()), + ) +} +enum Reply { + Ready(Value), + Wait(watch::Receiver, bool), +} +struct Terminal { + state: watch::Receiver, + kill: CancellationToken, +} +#[derive(Clone, Default)] +struct TerminalState { + output: String, + truncated: bool, + exit: Option>, +} + +// Decoding each stream independently preserves split UTF-8 code points. The bounded +// tail is valid UTF-8 even when its byte limit falls inside a multi-byte character. +fn append_output( + state: &mut TerminalState, + pending: &mut Vec, + bytes: &[u8], + eof: bool, + limit: usize, +) { + pending.extend_from_slice(bytes); + loop { + match std::str::from_utf8(pending) { + Ok(text) => { + state.output.push_str(text); + pending.clear(); + break; + } + Err(error) => { + let valid = error.valid_up_to(); + state + .output + .push_str(std::str::from_utf8(&pending[..valid]).unwrap_or_default()); + pending.drain(..valid); + match error.error_len() { + Some(n) => { + state.output.push('\u{fffd}'); + pending.drain(..n); + } + None if eof => { + state.output.push('\u{fffd}'); + pending.clear(); + break; + } + None => break, + } + } + } + } + if state.output.len() > limit { + let mut start = state.output.len() - limit; + while !state.output.is_char_boundary(start) { + start += 1; + } + state.output.drain(..start); + state.truncated = true; + } +} + +#[cfg(not(windows))] +type TerminalChild = tokio::process::Child; +#[cfg(windows)] +type TerminalChild = super::windows_process::WindowsChild; +#[cfg(target_os = "macos")] +async fn terminate_terminal_tree( + child: &mut TerminalChild, + pid: Option, + timeout: Duration, +) -> std::io::Result<()> { + let group = pid + .and_then(|pid| i32::try_from(pid).ok()) + .and_then(rustix::process::Pid::from_raw); + let deadline = tokio::time::Instant::now() + timeout.min(Duration::from_millis(500)); + loop { + match terminate_process_tree(child, pid).await { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + // Darwin can report EPERM while the exited group leader is + // being reaped. Only an absent group proves cleanup; a live + // group that remains inaccessible must still fail teardown. + let Some(group) = group else { + return Err(error); + }; + match rustix::process::test_kill_process_group(group) { + Err(rustix::io::Errno::SRCH) => return Ok(()), + Ok(()) | Err(rustix::io::Errno::PERM) => {} + Err(probe_error) => return Err(probe_error.into()), + } + if tokio::time::Instant::now() >= deadline { + return Err(error); + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + Err(error) => return Err(error), + } + } +} +async fn run_terminal( + #[cfg(unix)] mut guard: crate::opensymphony_workspace::ProcessGroupGuard, + mut child: TerminalChild, + limit: usize, + kill: CancellationToken, + shutdown: CancellationToken, + tx: watch::Sender, + timeout: Duration, +) -> bool { + #[cfg(not(windows))] + let pid = child.id(); + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + let mut out = [0u8; 4096]; + let mut err = [0u8; 4096]; + let mut pending_out = Vec::new(); + let mut pending_err = Vec::new(); + let mut out_open = true; + let mut err_open = true; + let mut state = TerminalState::default(); + let mut output_failed = false; + let status = loop { + tokio::select! { + biased; + _ = kill.cancelled() => break None, + _ = shutdown.cancelled() => break None, + result = child.wait() => break Some(result), + read = stdout.read(&mut out), if out_open => { + output_failed |= read.is_err(); + let n = read.unwrap_or(0); out_open = n != 0; + append_output(&mut state, &mut pending_out, &out[..n], !out_open, limit); tx.send_replace(state.clone()); + }, + read = stderr.read(&mut err), if err_open => { + output_failed |= read.is_err(); + let n = read.unwrap_or(0); err_open = n != 0; + append_output(&mut state, &mut pending_err, &err[..n], !err_open, limit); tx.send_replace(state.clone()); + }, + } + }; + #[cfg(target_os = "macos")] + let signalled = terminate_terminal_tree(&mut child, pid, timeout) + .await + .is_ok(); + #[cfg(all(not(windows), not(target_os = "macos")))] + let signalled = terminate_process_tree(&mut child, pid).await.is_ok(); + #[cfg(windows)] + let signalled = child.start_kill().is_ok(); + let status = match status { + Some(status) => status, + None => tokio::time::timeout(timeout, child.wait()) + .await + .unwrap_or_else(|_| Err(std::io::ErrorKind::TimedOut.into())), + }; + let drained = tokio::time::timeout(timeout, async { + while out_open || err_open { + tokio::select! { + read = stdout.read(&mut out), if out_open => { + let n = read.map_err(io_error)?; out_open = n != 0; + append_output(&mut state, &mut pending_out, &out[..n], !out_open, limit); + }, + read = stderr.read(&mut err), if err_open => { + let n = read.map_err(io_error)?; err_open = n != 0; + append_output(&mut state, &mut pending_err, &err[..n], !err_open, limit); + }, + } + } + Ok::<_, Error>(()) + }) + .await; + #[cfg(unix)] + if signalled && status.is_ok() { + guard.disarm(); + } + state.exit = Some(match status { + Ok(status) if signalled && !output_failed && matches!(drained, Ok(Ok(()))) => { + #[cfg(unix)] + let signal = { + use std::os::unix::process::ExitStatusExt; + status.signal().map(|signal| signal.to_string()) + }; + #[cfg(not(unix))] + let signal: Option = None; + Ok(json!({"exitCode":status.code(),"signal":signal})) + } + _ => Err(Error::new(-32603, "terminal teardown failed")), + }); + let ok = state.exit.as_ref().is_some_and(Result::is_ok); + tx.send_replace(state); + ok +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + #[tokio::test] + async fn callbacks_keep_original_workspace_after_root_path_replacement() { + let parent = tempfile::tempdir().expect("parent"); + let root = parent.path().join("COE-610"); + let moved = parent.path().join("moved"); + std::fs::create_dir(&root).expect("workspace"); + std::fs::write(root.join("marker"), "original").expect("original marker"); + let (_, mut service) = Services::new( + root.clone(), + BTreeMap::from([("PATH".into(), std::env::var("PATH").expect("PATH"))]), + HostServices { + read_files: true, + write_files: true, + terminals: true, + ..Default::default() + }, + ClientLimits::default(), + Arc::new(Mutex::new(CallbackOutput::default())), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + CancellationToken::new(), + CancellationToken::new(), + CancellationToken::new(), + ) + .expect("pinned workspace"); + std::fs::rename(&root, &moved).expect("move original root"); + std::fs::create_dir(&root).expect("replacement root"); + std::fs::write(root.join("marker"), "replacement").expect("replacement marker"); + + let Reply::Ready(read) = service + .handle("fs/read_text_file", json!({"path":root.join("marker")})) + .await + .expect("read from pinned root") + else { + panic!("read reply"); + }; + assert_eq!(read["content"], "original"); + service + .handle( + "fs/write_text_file", + json!({"path":root.join("written"),"content":"pinned"}), + ) + .await + .expect("write to pinned root"); + assert_eq!( + std::fs::read_to_string(moved.join("written")).expect("write"), + "pinned" + ); + assert!(!root.join("written").exists()); + + let Reply::Ready(created) = service + .handle( + "terminal/create", + json!({"command":"/bin/cat","args":["marker"]}), + ) + .await + .expect("terminal in pinned root") + else { + panic!("terminal reply"); + }; + let id = created["terminalId"].as_str().expect("terminal id"); + let mut state = service.terminals.get(id).expect("terminal").state.clone(); + state + .wait_for(|state| state.exit.is_some()) + .await + .expect("terminal exit"); + assert_eq!(state.borrow().output, "original"); + assert_eq!( + std::fs::read_to_string(root.join("marker")).expect("replacement"), + "replacement" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn terminal_cwd_uses_pinned_directory_after_rename_and_symlink_swap() { + let root = tempfile::tempdir().expect("root"); + let root = root.path().canonicalize().expect("canonical"); + let cwd = root.join("cwd"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::create_dir(&cwd).expect("cwd"); + std::fs::write(cwd.join("marker"), "original").expect("marker"); + std::fs::write(outside.path().join("marker"), "outside").expect("outside marker"); + let mut command = Command::new("/bin/cat"); + command.arg("marker"); + let root_directory = rustix::fs::open( + &root, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .expect("root pin"); + pin_terminal_cwd(&mut command, &root, &root_directory, &cwd).expect("pin"); + std::fs::rename(&cwd, root.join("moved")).expect("rename"); + std::os::unix::fs::symlink(outside.path(), &cwd).expect("swap"); + let output = command.output().await.expect("spawn"); + assert!(output.status.success()); + assert_eq!(output.stdout, b"original"); + assert!( + pin_terminal_cwd(&mut Command::new("/bin/cat"), &root, &root_directory, &cwd).is_err() + ); + } + + #[test] + fn facility_responses_fit_encoded_budgets_without_resource_failure() { + for frame_bytes in [256, 1024] { + for callback_bytes in [256, 1024] { + let limits = ClientLimits { + frame_bytes, + callback_bytes, + ..Default::default() + }; + let id = + agent_client_protocol::schema::v1::RequestId::from("callback-id".to_owned()); + for content in ["x".repeat(4096), "\0".repeat(4096), "😀".repeat(1024)] { + let file = bounded_response(Ok(json!({"content":content})), &id, &limits); + assert!(file.is_err()); + let terminal = bounded_response( + Ok(json!({"output":content,"truncated":false,"exitStatus":{"exitCode":0}})), + &id, + &limits, + ); + assert_eq!(terminal.as_ref().expect("tail")["truncated"], true); + for result in [file, terminal] { + let encoded = serde_json::to_string( + &agent_client_protocol::RawJsonRpcMessage::response(id.clone(), result), + ) + .expect("encode"); + assert!(CallbackOutput::default().admit(encoded, &limits)); + } + } + } + } + } + + #[test] + fn active_filesystem_callback_observes_turn_cancellation() { + let root = tempfile::tempdir().expect("root"); + let root = root.path().canonicalize().expect("canonical"); + let path = root.join("file"); + std::fs::write(&path, "original").expect("file"); + let runtime = tokio::runtime::Builder::new_current_thread() + .max_blocking_threads(1) + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async { + let cancellation = CancellationToken::new(); + let (_, mut service) = Services::new( + root, + BTreeMap::new(), + HostServices { + write_files: true, + ..Default::default() + }, + ClientLimits::default(), + Arc::new(Mutex::new(CallbackOutput::default())), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + CancellationToken::new(), + cancellation.clone(), + CancellationToken::new(), + ) + .expect("service root pinned"); + let (started, ready) = oneshot::channel(); + let (release, blocked) = std::sync::mpsc::channel(); + let blocking = tokio::task::spawn_blocking(move || { + let _ = started.send(()); + blocked.recv().expect("release"); + }); + ready.await.expect("blocking pool occupied"); + let request = service.handle_callback( + "fs/write_text_file", + json!({"path":path,"content":"changed"}), + ); + tokio::pin!(request); + // Poll the production path until its filesystem lookup is queued + // behind the occupied blocking pool, then cancel before I/O resumes. + std::future::poll_fn(|cx| { + assert!(request.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + cancellation.cancel(); + let result = tokio::time::timeout(Duration::from_millis(100), &mut request).await; + release.send(()).expect("release"); + blocking.await.expect("blocking task"); + let error = match result { + Ok(Err(error)) => error, + _ => panic!("active callback ignored cancellation"), + }; + assert_eq!(error.message, "session cancelled"); + }); + assert_eq!(std::fs::read_to_string(path).expect("file"), "original"); + } + + #[tokio::test] + async fn retained_turn_epoch_reaps_prior_terminals_without_cancelling_caller_tokens() { + let root = tempfile::tempdir().expect("root"); + let shutdown = CancellationToken::new(); + let (sender, mut service) = Services::new( + root.path().canonicalize().expect("root"), + BTreeMap::from([("PATH".into(), std::env::var("PATH").expect("path"))]), + HostServices { + terminals: true, + ..Default::default() + }, + ClientLimits::default(), + Arc::new(Mutex::new(CallbackOutput::default())), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + CancellationToken::new(), + CancellationToken::new(), + shutdown.clone(), + ) + .expect("service root pinned"); + service + .handle( + "terminal/create", + json!({"command":"python3","args":["-c","import time; time.sleep(60)"]}), + ) + .await + .expect("terminal"); + let mut terminal = service + .terminals + .values() + .next() + .expect("terminal") + .state + .clone(); + let task = tokio::spawn(service.run()); + let first = CancellationToken::new(); + sender + .begin_turn(first.clone(), Duration::from_secs(5)) + .await + .expect("prior process quiescent"); + assert!( + terminal + .wait_for(|state| state.exit.is_some()) + .await + .expect("exit") + .exit + .as_ref() + .expect("exit") + .is_ok() + ); + let second = CancellationToken::new(); + sender + .begin_turn(second.clone(), Duration::from_secs(5)) + .await + .expect("new epoch"); + assert!(!first.is_cancelled()); + assert!(!second.is_cancelled()); + shutdown.cancel(); + assert!(task.await.expect("actor")); + } + + #[tokio::test] + async fn queued_epoch_handoff_obeys_cancellation_and_deadline() { + // Retain the receiver without draining it: the epoch is admitted behind + // outstanding work, but the service cannot acknowledge it yet. + let (tx, _rx) = mpsc::channel(2); + let sender = CallbackSender { + tx, + permits: Arc::new(Semaphore::new(2)), + bytes: Arc::new(Semaphore::new(1024)), + }; + let cancellation = CancellationToken::new(); + let cancel = cancellation.clone(); + let pending = sender.begin_turn(cancellation, Duration::from_secs(60)); + let (result, ()) = tokio::join!(pending, async { + tokio::task::yield_now().await; + cancel.cancel(); + }); + assert!(matches!(result, Err(ClientError::CancelledBeforePrompt))); + assert!(matches!( + sender + .begin_turn(CancellationToken::new(), Duration::from_millis(1)) + .await, + Err(ClientError::SetupTimeout) + )); + } + + #[test] + fn terminal_tail_preserves_fragmented_utf8_and_byte_bounds() { + for limit in 0..16 { + let mut state = TerminalState::default(); + let mut pending = Vec::new(); + for byte in "start😀end".as_bytes() { + append_output(&mut state, &mut pending, &[*byte], false, limit); + assert!(state.output.len() <= limit); + } + append_output(&mut state, &mut pending, &[], true, limit); + assert!("start😀end".ends_with(&state.output)); + assert!(pending.is_empty()); + assert_eq!(state.truncated, limit < "start😀end".len()); + } + let mut state = TerminalState::default(); + let mut pending = Vec::new(); + append_output(&mut state, &mut pending, &[0xff, 0xf0, 0x9f], false, 16); + append_output(&mut state, &mut pending, &[], true, 16); + assert_eq!(state.output, "��"); + } +} diff --git a/crates/opensymphony-acp/src/session_config.rs b/crates/opensymphony-acp/src/session_config.rs new file mode 100644 index 000000000..92fddfbcb --- /dev/null +++ b/crates/opensymphony-acp/src/session_config.rs @@ -0,0 +1,281 @@ +//! Negotiated session selections; options remain private operational metadata. +use super::{AcpProfile, ClientError, SharedCapture, rpc_failure}; +use agent_client_protocol::{ + Agent, ConnectionTo, UntypedMessage, + schema::v1::{AgentCapabilities, McpServer}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct SessionConfiguration { + pub options: Vec, + pub current_mode: Option, + pub available_modes: Vec, +} +impl std::fmt::Debug for SessionConfiguration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionConfiguration") + .field("option_count", &self.options.len()) + .finish_non_exhaustive() + } +} +impl SessionConfiguration { + pub(super) fn initial(&mut self, session: &Value) { + self.options = session + .get("configOptions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + self.current_mode = session + .pointer("/modes/currentModeId") + .and_then(Value::as_str) + .map(str::to_owned); + self.available_modes = session + .pointer("/modes/availableModes") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + } + pub(super) fn update(&mut self, update: &Value) -> Result<(), agent_client_protocol::Error> { + match update.get("sessionUpdate").and_then(Value::as_str) { + Some("config_option_update") => { + self.options = update + .get("configOptions") + .and_then(Value::as_array) + .ok_or_else(agent_client_protocol::Error::invalid_params)? + .clone() + } + Some("current_mode_update") => { + self.current_mode = Some( + update + .get("currentModeId") + .and_then(Value::as_str) + .ok_or_else(agent_client_protocol::Error::invalid_params)? + .into(), + ) + } + _ => {} + } + Ok(()) + } + fn accepts(&self, id: &str, value: &str) -> bool { + self.options + .iter() + .find(|o| o.get("id").and_then(Value::as_str) == Some(id)) + .is_some_and(|o| { + o.get("type").and_then(Value::as_str) == Some("select") + && o.get("options") + .and_then(Value::as_array) + .is_some_and(|values| { + values.iter().any(|v| { + v.get("value").and_then(Value::as_str) == Some(value) + || v.get("options").and_then(Value::as_array).is_some_and( + |group| { + group.iter().any(|v| { + v.get("value").and_then(Value::as_str) + == Some(value) + }) + }, + ) + }) + }) + }) + } +} +fn unsupported(selection: &str) -> ClientError { + ClientError::Setup(format!( + "explicit session {selection} is not advertised or was not applied" + )) +} + +pub(super) fn validate_transports( + servers: &[McpServer], + capabilities: &AgentCapabilities, +) -> Result<(), ClientError> { + for server in servers { + let supported = match server { + McpServer::Stdio(_) => true, + McpServer::Http(_) => capabilities.mcp_capabilities.http, + McpServer::Sse(_) => capabilities.mcp_capabilities.sse, + _ => false, + }; + if !supported { + return Err(ClientError::Setup( + "required scoped MCP transport is not advertised by the agent".into(), + )); + } + } + Ok(()) +} + +pub(super) async fn apply( + connection: &ConnectionTo, + profile: &AcpProfile, + session: &str, + state: &std::sync::Arc>, + capture: &SharedCapture, +) -> Result<(), ClientError> { + let legacy_mode = state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .options + .is_empty(); + let mut selections = profile.session.options.clone(); + let mut pending = selections.clone(); + let mut categories = BTreeMap::new(); + if let Some(model) = &profile.session.model { + categories.insert("model", model.clone()); + } + if !legacy_mode && let Some(mode) = &profile.session.mode { + categories.insert("mode", mode.clone()); + } + while !pending.is_empty() || !categories.is_empty() { + { + let state = state.lock().unwrap_or_else(|e| e.into_inner()); + let mut resolved = Vec::new(); + for (&category, value) in &categories { + if let Some(option) = state + .options + .iter() + .find(|option| option.get("category").and_then(Value::as_str) == Some(category)) + { + let id = option + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| unsupported(category))?; + if selections.get(id).is_some_and(|existing| existing != value) { + return Err(unsupported("conflicting selections")); + } + selections.insert(id.to_owned(), value.clone()); + pending.insert(id.to_owned(), value.clone()); + resolved.push(category); + } + } + for category in resolved { + categories.remove(category); + } + } + let (id, value) = { + let state = state.lock().unwrap_or_else(|e| e.into_inner()); + pending + .iter() + .filter(|(id, value)| state.accepts(id, value)) + .min_by_key(|(id, _)| { + match state + .options + .iter() + .find(|option| { + option.get("id").and_then(Value::as_str) == Some(id.as_str()) + }) + .and_then(|option| option.get("category")) + .and_then(Value::as_str) + { + Some("model") => 0, + Some("mode") => 1, + _ => 2, + } + }) + .map(|(id, value)| (id.clone(), value.clone())) + .ok_or_else(|| unsupported("config option"))? + }; + pending.remove(&id); + let request = UntypedMessage::new( + "session/set_config_option", + json!({"sessionId":session,"configId":id,"value":value}), + ) + .map_err(|error| configuration_rpc_error("session/set_config_option", &error, capture))?; + let state = state.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + // Commit response metadata inside ordered dispatch, before adjacent updates. + connection + .send_request(request) + .on_receiving_result(async move |result| { + let result = result.and_then(|result| { + let options = result + .get("configOptions") + .and_then(Value::as_array) + .ok_or_else(agent_client_protocol::Error::invalid_params)?; + state.lock().unwrap_or_else(|e| e.into_inner()).options = options.clone(); + Ok(()) + }); + let _ = tx.send(result); + Ok(()) + }) + .map_err(|error| { + configuration_rpc_error("session/set_config_option", &error, capture) + })?; + rx.await + .map_err(|_| ClientError::Disconnected { submitted: false })? + .map_err(|error| { + configuration_rpc_error("session/set_config_option", &error, capture) + })?; + } + let legacy_mode = legacy_mode.then(|| profile.session.mode.clone()).flatten(); + if let Some(mode) = legacy_mode { + if !state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .available_modes + .iter() + .any(|m| m.get("id").and_then(Value::as_str) == Some(&mode)) + { + return Err(unsupported("mode")); + } + let request = UntypedMessage::new( + "session/set_mode", + json!({"sessionId":session,"modeId":mode}), + ) + .map_err(|error| configuration_rpc_error("session/set_mode", &error, capture))?; + let state = state.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + connection + .send_request(request) + .on_receiving_result(async move |result| { + if result.is_ok() { + state.lock().unwrap_or_else(|e| e.into_inner()).current_mode = Some(mode); + } + let _ = tx.send(result); + Ok(()) + }) + .map_err(|error| configuration_rpc_error("session/set_mode", &error, capture))?; + rx.await + .map_err(|_| ClientError::Disconnected { submitted: false })? + .map_err(|error| configuration_rpc_error("session/set_mode", &error, capture))?; + } + let state = state.lock().unwrap_or_else(|e| e.into_inner()); + if state.options.is_empty() + && profile + .session + .mode + .as_ref() + .is_some_and(|mode| state.current_mode.as_ref() != Some(mode)) + { + return Err(unsupported("mode")); + } + for (id, value) in selections { + if !state.options.iter().any(|o| { + o.get("id").and_then(Value::as_str) == Some(&id) + && o.get("currentValue").and_then(Value::as_str) == Some(&value) + }) { + return Err(unsupported("config option")); + } + } + Ok(()) +} + +fn configuration_rpc_error( + method: &'static str, + error: &agent_client_protocol::Error, + capture: &SharedCapture, +) -> ClientError { + if error.code == agent_client_protocol::schema::v1::ErrorCode::AuthRequired { + ClientError::AuthenticationRequired { submitted: false } + } else if agent_client_protocol::is_incoming_transport_closed(error) { + ClientError::Disconnected { submitted: false } + } else { + rpc_failure(method, error, capture, false) + .unwrap_or(ClientError::Protocol { submitted: false }) + } +} diff --git a/crates/opensymphony-acp/src/windows_atomic.rs b/crates/opensymphony-acp/src/windows_atomic.rs new file mode 100644 index 000000000..f428e20a3 --- /dev/null +++ b/crates/opensymphony-acp/src/windows_atomic.rs @@ -0,0 +1,125 @@ +//! Handle-only Windows stage promotion and cancellation cleanup. +use std::{ + ffi::OsStr, + fs::{File, OpenOptions}, + io, + os::windows::{ffi::OsStrExt, fs::OpenOptionsExt, io::AsRawHandle}, + path::Path, +}; +use windows_sys::{ + Wdk::Storage::FileSystem::{ + FILE_RENAME_INFORMATION, FileRenameInformation, NtSetInformationFile, + }, + Win32::{ + Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE, RtlNtStatusToDosError}, + Storage::FileSystem::{ + DELETE, FILE_DISPOSITION_INFO, FILE_SHARE_READ, FileDispositionInfo, + SetFileInformationByHandle, + }, + System::IO::IO_STATUS_BLOCK, + }, +}; + +pub(super) fn create_stage(path: &Path) -> io::Result { + OpenOptions::new() + .read(true) + .write(true) + .access_mode(GENERIC_READ | GENERIC_WRITE | DELETE) + // Neither another writer nor a rename can change this stage before + // its handle-relative promotion. Clones refer to the same file object. + .share_mode(FILE_SHARE_READ) + .create_new(true) + .open(path) +} + +#[repr(C)] +struct RenameInformation { + replace: u32, + root: HANDLE, + name_bytes: u32, + name: [u16; 256], +} +const _: () = { + assert!( + std::mem::offset_of!(RenameInformation, root) + == std::mem::offset_of!(FILE_RENAME_INFORMATION, RootDirectory) + ); + assert!( + std::mem::offset_of!(RenameInformation, name_bytes) + == std::mem::offset_of!(FILE_RENAME_INFORMATION, FileNameLength) + ); + assert!( + std::mem::offset_of!(RenameInformation, name) + == std::mem::offset_of!(FILE_RENAME_INFORMATION, FileName) + ); + assert!( + std::mem::align_of::() + == std::mem::align_of::() + ); +}; + +#[allow(unsafe_code)] +pub(super) fn replace_in_same_directory(file: &File, leaf: &OsStr) -> io::Result<()> { + let name: Vec<_> = leaf.encode_wide().collect(); + if name.is_empty() + || name.len() > 255 + || name.iter().any(|c| matches!(*c, 0 | 47 | 58 | 92)) + || leaf == "." + || leaf == ".." + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let mut information = RenameInformation { + replace: 1, + root: std::ptr::null_mut(), + name_bytes: (name.len() * 2) as u32, + name: [0; 256], + }; + information.name[..name.len()].copy_from_slice(&name); + let mut status = IO_STATUS_BLOCK::default(); + // SAFETY: file is a live, synchronous DELETE-access handle. The aligned + // repr(C) buffer matches the SDK layout (asserted above), includes the full + // bounded UTF-16 leaf, and lives through this synchronous call. A null root + // plus a simple leaf renames within the source file's directory; source and + // all ancestor handles deny delete/write sharing, so neither can move. + // Win32 path rename opens the target parent for write and conflicts with + // those guards. The NT same-directory form requires no such parent open. + let result = unsafe { + NtSetInformationFile( + file.as_raw_handle(), + &mut status, + std::ptr::from_ref(&information).cast(), + std::mem::size_of_val(&information) as u32, + FileRenameInformation, + ) + }; + if result == 0 { + Ok(()) + } else { + // SAFETY: pure status-code translation with no pointers or ownership. + Err(io::Error::from_raw_os_error( + unsafe { RtlNtStatusToDosError(result) } as i32, + )) + } +} + +#[allow(unsafe_code)] +pub(super) fn delete_on_close(file: &File) -> io::Result<()> { + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: file is a live DELETE-access handle and disposition is a correctly + // sized SDK structure kept alive throughout this synchronous call. This + // marks the owned file object, never a potentially substituted path. + let result = unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FileDispositionInfo, + std::ptr::from_ref(&disposition).cast(), + std::mem::size_of_val(&disposition) as u32, + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} diff --git a/crates/opensymphony-acp/src/windows_path.rs b/crates/opensymphony-acp/src/windows_path.rs new file mode 100644 index 000000000..e4e884612 --- /dev/null +++ b/crates/opensymphony-acp/src/windows_path.rs @@ -0,0 +1,150 @@ +//! Pin Windows callback paths so validation cannot be invalidated by a junction swap. +use std::{ + io, + os::windows::fs::MetadataExt, + path::{Component, Path}, +}; +use tokio::fs::{File, OpenOptions}; + +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; +const FILE_SHARE_READ: u32 = 1; + +/// Keep every ancestor pinned from the drive/share root. Excluding write and +/// delete sharing prevents renaming a checked directory or turning it into a +/// reparse point until the callback finishes using the path. +pub(super) async fn pin_directory(path: &Path, create: bool) -> io::Result> { + if !path.is_absolute() { + return Err(io::ErrorKind::InvalidInput.into()); + } + let mut current = std::path::PathBuf::new(); + let mut guards = Vec::new(); + for component in path.components() { + if matches!(component, Component::ParentDir) { + return Err(io::ErrorKind::InvalidInput.into()); + } + current.push(component); + if matches!(component, Component::Prefix(_)) { + continue; + } + if create && matches!(component, Component::Normal(_)) { + match tokio::fs::create_dir(¤t).await { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + } + let directory = OpenOptions::new() + // Attribute-only handles do not participate in Windows sharing checks. + .read(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(¤t) + .await?; + let metadata = directory.metadata().await?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::ErrorKind::InvalidInput.into()); + } + guards.push(directory); + } + Ok(guards) +} + +pub(super) async fn open_file(path: &Path, write: bool) -> io::Result<(File, Vec)> { + let guards = pin_directory(path.parent().ok_or(io::ErrorKind::InvalidInput)?, write).await?; + // OPEN_ALWAYS (create without truncate) and OPEN_REPARSE_POINT let us + // inspect the opened leaf before any content mutation occurs. + let file = OpenOptions::new() + .read(!write) + .write(write) + .create(write) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .await?; + let metadata = file.metadata().await?; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::ErrorKind::InvalidInput.into()); + } + Ok((file, guards)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + + fn junction(link: &Path, target: &Path) { + assert!( + std::process::Command::new("cmd.exe") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .status() + .expect("create junction") + .success() + ); + } + + #[tokio::test] + async fn junctions_cannot_supply_callback_files_or_terminal_directories() { + let root = tempfile::tempdir().expect("workspace"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::write(outside.path().join("existing"), "outside").expect("file"); + let link = root.path().join("junction"); + junction(&link, outside.path()); + assert!(open_file(&link.join("existing"), false).await.is_err()); + assert!( + open_file(&link.join("new/destination"), true) + .await + .is_err() + ); + assert!(pin_directory(&link, false).await.is_err()); + assert!(!outside.path().join("new").exists()); + std::fs::remove_dir(link).expect("remove junction only"); + assert_eq!( + std::fs::read_to_string(outside.path().join("existing")).expect("untouched"), + "outside" + ); + } + + #[tokio::test] + async fn pinned_callback_paths_reject_swaps_until_file_io_and_spawn_finish() { + let root = tempfile::tempdir().expect("workspace"); + let inside = root.path().join("inside"); + std::fs::create_dir(&inside).expect("directory"); + let path = inside.join("new/leaf"); + let (mut file, guards) = open_file(&path, true).await.expect("contained write"); + let moved = root.path().join("moved"); + assert!( + std::fs::rename(&inside, &moved).is_err(), + "ancestor must stay pinned" + ); + assert!( + std::fs::rename(&path, inside.join("other")).is_err(), + "leaf must stay pinned" + ); + file.write_all(b"inside").await.expect("write while pinned"); + file.flush().await.expect("flush"); + drop(file); + drop(guards); + let cwd = pin_directory(&inside, false).await.expect("terminal cwd"); + assert!(std::fs::rename(&inside, &moved).is_err()); + assert!( + tokio::process::Command::new("cmd.exe") + .args(["/C", "exit", "0"]) + .current_dir(&inside) + .status() + .await + .expect("spawn under pinned cwd") + .success() + ); + drop(cwd); + std::fs::rename(&inside, &moved).expect("unpin releases rename exclusion"); + assert_eq!( + std::fs::read(moved.join("new/leaf")).expect("contained output"), + b"inside" + ); + } +} diff --git a/crates/opensymphony-workflow/src/acp.rs b/crates/opensymphony-workflow/src/acp.rs index 41103918a..b3a495638 100644 --- a/crates/opensymphony-workflow/src/acp.rs +++ b/crates/opensymphony-workflow/src/acp.rs @@ -29,6 +29,18 @@ pub struct AcpProfile { pub required_capabilities: Vec, #[serde(default)] pub extensions: Vec, + #[serde(default)] + pub session: AcpSessionConfig, +} + +/// Explicit advertised session selections. Values are opaque IDs, never credentials. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AcpSessionConfig { + pub model: Option, + pub mode: Option, + #[serde(default)] + pub options: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -124,6 +136,22 @@ impl AcpProfile { "auth.method_id must be a nonempty bounded opaque identifier without control characters", )); } + if self.session.options.len() > 32 + || self + .session + .model + .iter() + .chain(self.session.mode.iter()) + .chain(self.session.options.keys()) + .chain(self.session.options.values()) + .any(|value| { + value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) + }) + { + return Err(invalid( + "session selections must be bounded nonempty opaque identifiers", + )); + } if !self.extensions.is_empty() { return Err(invalid( "no extension handlers are implemented; extensions must be empty", diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index 077c19548..20c45bc73 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -1,5 +1,5 @@ mod acp; -pub use acp::{AcpAuth, AcpConfig, AcpProfile}; +pub use acp::{AcpAuth, AcpConfig, AcpProfile, AcpSessionConfig}; mod error; mod loader; mod model; diff --git a/docs/architecture.md b/docs/architecture.md index 6a6cc083f..6602f0ef9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -499,8 +499,15 @@ writer control; scheduler holds and writer transfer belong to OSYM-907. Handlers are installed before initialization. Permission callbacks receive the protocol cancellation outcome; unknown requests receive method-not-found and unknown notifications receive no response. Updates are processed before the -prompt response is returned. The client advertises no filesystem or terminal -capabilities. No OpenHands server or client participates in this launch path. +prompt response is returned. Host policy gates filesystem and terminal +advertisements. The ordered dispatch handler admits callbacks to one bounded +connection-owned actor. File operations are serialized; terminal waits use +bounded asynchronous responses so they cannot block RPC dispatch. Terminal +processes use the existing process-group or Windows Job Object supervisors. +Each retained prompt first retires the prior callback epoch through a bounded, +cancellable preparation step while the owner continues servicing commands. Only +then does it persist submission and dispatch the prompt. Session config responses +and updates are committed in SDK dispatch order before prompt completion. No OpenHands server or client participates in this launch path. diff --git a/docs/configuration.md b/docs/configuration.md index a55800435..37f4d8f15 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -989,9 +989,40 @@ references. Optional `required_capabilities` supports `prompt.image`, `prompt.audio`, and `prompt.embedded_context`; each requirement is checked before session creation, and a failed check names the missing capability. -The current prompt API sends text. ACP model overrides are rejected until the -session configuration implementation is available. Filesystem, terminal, MCP, -extension, and operator permission policies are follow-on slices. +The prompt API sends text. Explicit session selections live in each profile: + +```yaml +session: + model: model-id + mode: code + options: + thought-level: high +``` + +`session.model` and `session.mode` select advertised config options by category; +`session.options` selects by exact option ID, including grouped select values. +The client applies choices before every prompt, including retained turns, +validates the returned complete option list, and consumes subsequent config and +mode updates. Currently supported +model/mode prerequisites apply before dependent options, using the refreshed +advertisements after each response. Legacy +`session/set_mode` is used when the peer supplies modes without config options. +Unsupported explicit choices fail setup. Boolean options and legacy experimental +model RPCs are not advertised. The generic routing model override remains +unavailable for ACP; use the profile's explicit session selection. + +The host supplies `LaunchContext.services: HostServices`. Its `read_files`, +`write_files`, and `terminals` flags default to false and enable only the matching +implemented callbacks. `mcp_servers` contains resolved host-owned scoped MCP +attachments: stdio is baseline support, while HTTP and SSE require the peer's +advertisement. Every supplied server is required; unsupported transports fail +before session creation. Existing memory grants can be passed as an HTTP server +named `opensymphony-memory` with the issued Authorization header. Resolved values +stay out of the profile. Every stdio MCP argument value is structurally masked +in captured requests; credential option values in separate and equals forms, +including generic header values, are also redacted from echoed events and +stderr. The peer receives the exact host-supplied attachment. Operator +responses and extensions remain separate slices. ACP credential arguments such as `--access-token`, `--oauth2-bearer`, `--client-secret`, and `--pat` are rejected in separate-value and equals forms, @@ -999,6 +1030,18 @@ including mixed case; credentials belong in `env_refs`. A top-level `acp` sectio selects the central configuration parser, so incomplete central files fail validation before legacy defaults can be applied. +ACP callback text is bounded by both its facility limit and the complete encoded +response budget, including JSON escaping and the request ID. A file response that +does not fit returns a callback parameter error; terminal output retains a UTF-8 +tail and reports truncation. Scoped HTTP/SSE MCP endpoints must use HTTP or HTTPS +without URL userinfo, fragments or query strings; pass resolved grants in headers. +MCP attachments cannot include resolved excluded checkout credentials, including +values aliased under another environment name, header or argument. Filesystem +contents and terminal output remain intact on the wire and are structurally +redacted from source history and evidence. Model and mode categories are +resolved from each refreshed option list so prerequisite selections can reveal +dependent options. + ## Current model diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index df5194e34..585a77b27 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -118,7 +118,24 @@ Known gaps: `opensymphony_acp::run_turn` implements an SDK-backed ACP v1 stdio session through setup, one text prompt, ordered updates, cancellation, and supervised teardown. -Only the executable client and typed launch profiles are available in this slice. +The executable client includes typed launch profiles and host-owned callbacks. ACP is not yet advertised as a runnable scheduler harness; production routing, -retained sessions, negotiated public capabilities, complete callbacks and operator +negotiated public capabilities and operator responses follow the [ACP runtime task package](tasks/acp-runtime-ide-task-package.yaml). + + +| ACP client surface | Implemented contract | +| --- | --- | +| `fs/read_text_file` | Opt-in; session-bound UTF-8, 1-based lines, optional line count, preserved line endings, bounded file size. | +| `fs/write_text_file` | Opt-in; contained absolute paths, checked missing parents, bounded content, regular files. | +| `terminal/*` | Opt-in; create/output/wait/kill/release, connection-local random IDs, immutable host environment, contained cwd, bounded UTF-8 output. | +| Session configuration | Advertised select options and grouped values; explicit model/mode choices; legacy modes; ordered updates. | +| MCP | Required host-scoped stdio attachments; HTTP/SSE only after agent negotiation. | +| Permission | Cancelled response until operator routing is implemented. | +| Interactive auth and elicitation | Not advertised. | + +Windows callback operations pin path ancestors through use and reject reparse +points, including junctions, through no-follow handles. Callback epoch handoff shares the setup +deadline and observes caller cancellation before prompt submission. + +Callback policies do not establish a sandbox for the local agent process. diff --git a/docs/operations.md b/docs/operations.md index 8060c5d87..2df83b2a1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -940,6 +940,19 @@ requested cancellation. Sending `session/cancel`, killing a process, or receivin an unrelated stop reason does not establish that acknowledgement. Prompt failures after possible submission are uncertain and are never retried by this client. + +ACP client facilities add independent defaults of 256 KiB per text file, 64 KiB +of retained output per terminal, 16 terminal handles/processes, 64 pending +callbacks, and a 300-second callback deadline. Pending callbacks also share a +`queued_bytes` byte budget independent of transport ingress. Callback response +reservations remain charged until stdin flush. An exceeded admission budget +fails the run visibly and tears down owned processes. Host policy disables all +filesystem and terminal callbacks by default. Output discards oldest characters +at UTF-8 boundaries; zero output retention is allowed. Scoped MCP headers, +environment values and credential option arguments join the redactor before any +wire frame is captured. Configuration RPC failures retain method, error code and +redacted context; authentication failures retain their dedicated classification. + Retained ACP ownership is available through `SessionHost`. A finished worker may borrow the same live process again; nonpersistent agents remain attachable while that process is alive. Owner loss allows only capability-gated restoration of a diff --git a/docs/sources.md b/docs/sources.md index 207eddbb9..d97d46c32 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -247,7 +247,19 @@ terminates descendants when ownership ends. Wire authority: [v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization), [v1 prompt/cancel lifecycle](https://agentclientprotocol.com/protocol/v1/prompt-turn), and [v1 transports](https://agentclientprotocol.com/protocol/v1/transports). -Implementation and tests are in `crates/opensymphony-acp/src/lib.rs` and `tests/acp.rs`. +Client facilities follow the [v1 filesystem](https://agentclientprotocol.com/protocol/v1/file-system), +[v1 terminals](https://agentclientprotocol.com/protocol/v1/terminals), +[v1 session config options](https://agentclientprotocol.com/protocol/v1/session-config-options) +and [v1 MCP attachments](https://agentclientprotocol.com/protocol/v1/session-setup) +contracts. Optional callback fields use strict local deserialization so malformed +line, cwd, environment and output limits cannot silently become defaults. +Implementation and tests are in `crates/opensymphony-acp/src/` and `tests/acp.rs`. + +Windows callback path ownership uses documented [CreateFile sharing and reparse +flags](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew) +through [Rust OpenOptionsExt](https://doc.rust-lang.org/std/os/windows/fs/trait.OpenOptionsExt.html). +No-follow handles are checked before mutation, and ancestor handles exclude +write/delete sharing until file I/O or terminal spawn finishes. diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 5d3b96df2..152598389 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -629,6 +629,15 @@ That document covers: ## ACP stdio client tests +`cargo test-system-duckdb --test acp` includes the executable +`tests/fixtures/acp_services_peer.py` peer. It exercises line ranges and endings, +invalid UTF-8, missing write parents, traversal/symlinks, oversized content, +capability absence, cross-session rejection, UTF-8 terminal truncation, exit +races, release-before-exit, cancellation/reaping, immutable host policy, +advertised configuration changes and an authenticated scoped MCP HTTP attachment. +These deterministic tests establish client behavior; live harness qualification +is tracked separately in OSYM-906. + Run `cargo test-system-duckdb --test acp` for the executable client contract and `cargo test-system-duckdb --lib acp` for central configuration coverage. The reusable `tests/fixtures/acp_peer.py` peer validates initialize/authenticate/new/prompt, @@ -645,12 +654,44 @@ adjacent response/update pair is dispatched, and saturate callback output while a peer stops reading stdin. Count and encoded-byte budgets cover successful, unknown and invalid permission callbacks; sequential round trips verify that completed transport writes release reservations. +Native Unix tests replace the bound workspace root after service creation and +verify file reads, atomic writes, and new terminal cwd remain on the original +directory inode. They also swap a pinned terminal directory for an external +symlink before spawn and verify it reads from the original directory. A narrow +`unsafe_code` allowance wraps child-only `pre_exec` registration for `fchdir`; +the repository lint is `deny`, and the closure performs no allocation, locking, +logging or host cwd mutation. Callback tests cancel a file operation queued behind +a blocked filesystem worker and verify no write occurs. Executable peers exercise +partial staged-write cancellation and atomic replacement on native platforms, +ordinary MCP environment values beside secret grants, JSON-escaped file/terminal +responses against smaller response budgets, generic credential-bearing MCP +header arguments with exact wire delivery and masked evidence, a one-turn +prompt response immediately followed by a denied file write, deferred +model-to-mode selection, and rejection of credentials embedded in MCP URLs. +Retained-owner fixtures reject abandoned-session callbacks adjacent to a missing +restoration response, reapply changed model/mode/options before the next prompt, +and keep cancellation/deadline failures ahead of durable submission. +The retained host also blocks the filesystem worker during the durable submission +checkpoint, cancels the accepted prompt, and verifies that no prompt reaches the +peer and the persisted marker closes as `cancelled_before_prompt`. On macOS, +the normal-completion retained fixture repeats short-lived terminal callbacks +through successive prompts while the full host suite runs in parallel, covering +the natural-exit process-group reap race. The `acp-windows` CI job runs `python scripts/validation/check-acp-windows.py` on Windows. Its temporary Cargo harness compiles the production Windows process owner and verifies descendant termination on normal teardown, parent exit, wait deadline, and dropped futures. It also executes the shared environment replacement helper against a real child to verify case-insensitive alias -precedence. Dependencies are read from the root manifest. On another host, +precedence. The shared Windows callback path validator rejects real junctions +for existing reads, nonexistent writes and terminal directories; pinned-handle +tests attempt directory and leaf swaps during I/O and terminal spawn. Windows SDK +bindings support same-directory stage promotion and deletion by +handle. Two small audited unsafe wrappers use synchronous owned handles and +bounded SDK-layout buffers; native tests verify successful atomic replacement, +original preservation on cancellation and promotion errors, stage/parent rename +exclusion, and deletion after an outstanding write handle closes. The temporary +validation harness uses the same deny-by-default unsafe lint as the root crate. +Dependencies are read from the root manifest. On another host, `--check-target x86_64-pc-windows-gnu` checks compilation with that Rust target installed; cross-compilation alone does not prove Windows process behavior. Run `cargo fmt --check` and `cargo clippy-system-duckdb`; dependency changes also @@ -661,6 +702,15 @@ later live-qualification task and must not be inferred from fake-peer tests. with executable subprocesses: repeated attempts, concurrent issues, live leases, idle expiry, cancellation/busy fencing, negotiated load/replay and resume, fresh nonpersistent reset, durable uncertain-submission refusal and stale generations. +Callback integration covers cancellation followed by a fresh turn on the same +process, stale terminal rejection, live configuration and secret-safe host-policy/ +MCP grant compatibility checks. Normal-completion tests send adjacent late file +and terminal callbacks, verify rejection while idle and process reaping before +completion, then start a fresh callback epoch. Pre-prompt tests reject file and +terminal requests after session binding. File and terminal-output tests verify +exact wire contents with structural redaction in retained history and evidence. +Configuration tests check preparation-frame attribution on success, cancellation +and timeout; MCP tests reject query strings and aliased excluded credentials. The peer verifies that the submitted marker is already on disk when a prompt arrives. `cargo test-system-duckdb --lib opensymphony_acp::durable::tests` covers owner locks, process-group loss, the launch checkpoint gap, compatibility bounds, diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 4eeb4b2b6..ba51a562d 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -928,15 +928,21 @@ reintroduce excluded credentials. `SessionHost` retains one supervised process/connection per issue. A stable `.opensymphony/acp-owner.lock` prevents competing owners without creating another -session database. The conversation manifest records the profile fingerprint, +session database. The profile fingerprint also hashes host callback policy, +client resource limits and resolved MCP attachments, so changed limits, facilities +or grants reject session reuse +without persisting credential values. The conversation manifest records that fingerprint, credential/grant revision, exact workspace/repository/checkout binding, opaque session ID, run/attempt, connection generation, negotiated capabilities and submission/outcome state. Native manifests remain readable and cannot be silently replaced by ACP. Every command checks the owning connection generation. The manifest is atomically replaced and synced, including its parent directory, -before a prompt can reach the transport. `submitted` without a known terminal -outcome becomes `uncertain` and prevents automatic recovery or prompt replay. +before a prompt can reach the transport. Cancellation after the synced +`submitted` marker is rechecked before transport submission; when no prompt was +sent, a `cancelled_before_prompt` outcome closes that marker. `submitted` +without a known terminal outcome becomes `uncertain` and prevents automatic +recovery or prompt replay. Local EOF, process reaping and protocol cancellation acknowledgement are separate evidence. Workspace cleanup remains fenced after an uncertain remote outcome. @@ -953,6 +959,58 @@ Job Object. Retirement waits for active work and leases to clear, then terminate and reaps owned process resources before acknowledging cleanup. Trusted local host execution provides filesystem/process access; it is not a sandbox. + +Filesystem callbacks require absolute paths in the bound workspace and reject +parent traversal, escaping or dangling symlinks and non-regular file targets. +On Unix, the service actor pins the verified workspace directory before the +agent starts, and the agent child enters that same directory inode with +`fchdir`. Descriptor-relative callback opens stay anchored to that root even +if its original pathname is replaced, reject every symlink component, +including in-workspace links, and use no-follow opens for new files. Terminal +launch traverses from the same pinned root; its child enters the selected +directory using `fchdir` before executing the command. On macOS, terminal +teardown retries a transient process-group permission error after natural exit; +cleanup succeeds only when group signaling succeeds or the group is confirmed +absent. An inaccessible live group remains a teardown failure. Windows pins every +ancestor without write/delete sharing, rejects reparse points through opened +handles, and holds those guards through file I/O or terminal spawn. Writes create missing +parent directories only after containment validation. File writes stage in the +same directory and atomically replace the destination after a complete flush; +cancellation or I/O failure removes the stage and preserves the original file. +Unix replacement remains relative to the pinned parent descriptor. Windows uses +a same-directory NT rename on the owned stage handle, with parent and stage +handles denying write/delete sharing through promotion. Cancellation marks the +stage for deletion by handle, including when a blocking write still owns a +clone. Terminal cwd defaults to +the same workspace; alternate directories must remain inside it. Terminal env +entries may only repeat existing host-owned values, protecting executable lookup +and scoped grants from callback overrides. + +Each terminal belongs to one connection/session and receives an unguessable ID. +Release invalidates the ID immediately, kills outstanding work and waits for +reaping. Kill preserves the handle and its final output. Cancellation stops all +owned terminal trees and interrupts in-flight callback waits; connection teardown expires callbacks and waits for process +cleanup. SDK dispatch continues while terminal wait requests are pending. +A retained owner calls `begin_turn` before the next prompt to retire the prior +callback epoch, reap its processes, invalidate old handles and install a fresh +cancellation token before the durable submission marker. Initial idle readiness +has no active callback epoch; callbacks after session binding remain denied until +`begin_turn` installs the first epoch. Both one-turn and retained prompt responses close their callback epochs +inside ordered dispatch, before adjacent callbacks can run; terminal and callback cleanup +finishes before the owner publishes `Finished` or returns the turn report. The +owner keeps handling +observation and shutdown commands while preparation waits; cancellation, timeout +or cleanup failure ends the connection before the new prompt. The same bounded, +cancellable preparation reapplies explicit model, mode and option choices after +agent configuration updates, before recording durable submission. Preparation +frames carry the accepted run identity, including when preparation fails before +submission. Missing-context +restoration clears the callback binding inside ordered response dispatch and +retires accepted restoration work before creating a fresh session. +`HostServices` is captured at connection creation and has no attachment mutation +path, so editor-like extension requests cannot replace cwd, environment, callbacks +or MCP grants. + ## Current model diff --git a/scripts/validation/check-acp-windows.py b/scripts/validation/check-acp-windows.py index 9da645b05..959742cac 100644 --- a/scripts/validation/check-acp-windows.py +++ b/scripts/validation/check-acp-windows.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compile/test the production Windows ACP owner without unrelated CLI dependencies. +"""Compile/test the production Windows ACP helpers without unrelated CLI dependencies. The temporary Cargo harness is not a workspace member or published package. Its runtime dependency definitions come directly from the root manifest. @@ -24,6 +24,7 @@ config = tomllib.loads((root / "Cargo.toml").read_text()) deps = { "process-wrap": config["target"]["cfg(windows)"]["dependencies"]["process-wrap"], + "windows-sys": config["target"]["cfg(windows)"]["dependencies"]["windows-sys"], "tokio": config["workspace"]["dependencies"]["tokio"], "tempfile": config["workspace"]["dependencies"]["tempfile"], } @@ -37,7 +38,7 @@ def toml(value): harness = Path(temp) (harness / "Cargo.toml").write_text( '[package]\nname = "acp-windows-validation"\nversion = "0.0.0"\nedition = "2024"\n' - '[lib]\npath = "lib.rs"\n[lints.rust]\nunsafe_code = "forbid"\n[dependencies]\n' + '[lib]\npath = "lib.rs"\n[lints.rust]\nunsafe_code = "deny"\n[dependencies]\n' + "\n".join(f"{key} = {toml(value)}" for key, value in deps.items()) + "\n" ) (harness / "lib.rs").write_text( @@ -45,7 +46,11 @@ def toml(value): + json.dumps(str(root / "crates/opensymphony-acp/src/windows_process.rs")) + ']\nmod windows_process;\n#[path = ' + json.dumps(str(root / "crates/opensymphony-workspace/src/environment.rs")) - + ']\nmod environment;\n' + + ']\nmod environment;\n#[path = ' + + json.dumps(str(root / "crates/opensymphony-acp/src/windows_path.rs")) + + ']\nmod windows_path;\n#[path = ' + + json.dumps(str(root / "crates/opensymphony-acp/src/atomic_file.rs")) + + ']\nmod atomic_file;\n' ) shutil.copyfile(root / "Cargo.lock", harness / "Cargo.lock") command = ["cargo", "check", "--tests", "--target", args.check_target] if args.check_target else ["cargo", "test"] diff --git a/tests/acp.rs b/tests/acp.rs index ba7058335..20735dc34 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -24,6 +24,7 @@ fn profile(mode: &str) -> AcpProfile { }), required_capabilities: vec![], extensions: vec![], + session: Default::default(), } } fn context(root: &Path) -> LaunchContext { @@ -39,6 +40,7 @@ fn context(root: &Path) -> LaunchContext { ("CHECKOUT_SECRET".into(), "checkout-secret-value".into()), ]), excluded_environment: BTreeSet::from(["CHECKOUT_SECRET".into()]), + services: Default::default(), } } fn limits() -> ClientLimits { @@ -1065,3 +1067,744 @@ async fn acp_env_reference_sources_are_retained_only_when_explicitly_targeted() ); } } + +fn services_profile(mode: &str) -> AcpProfile { + let mut profile = profile(mode); + profile.args[0] = format!( + "{}/tests/fixtures/acp_services_peer.py", + env!("CARGO_MANIFEST_DIR") + ); + profile.auth = None; + profile +} +fn services_context(root: &Path) -> LaunchContext { + let mut context = context(root); + context.services = opensymphony::opensymphony_acp::HostServices { + read_files: true, + write_files: true, + terminals: true, + ..Default::default() + }; + context + .environment + .insert("HOST_SCOPE".into(), "COE-610".into()); + context +} +fn services_limits() -> ClientLimits { + ClientLimits { + file_bytes: 1024, + terminal_output_bytes: 2048, + prompt_timeout: Duration::from_secs(10), + ..limits() + } +} +#[tokio::test] +async fn acp_client_services_files_enforce_text_limits_and_containment() { + let root = tempfile::tempdir().expect("root"); + let context = services_context(root.path()); + std::fs::write(context.issue_workspace.join("oversized"), vec![b'x'; 1025]).expect("file"); + std::fs::write(context.issue_workspace.join("invalid-utf8"), [0xff]).expect("file"); + #[cfg(unix)] + { + std::os::unix::fs::symlink(root.path(), context.issue_workspace.join("outside")) + .expect("link"); + std::os::unix::fs::symlink( + root.path().join("nonexistent"), + context.issue_workspace.join("dangling"), + ) + .expect("link"); + } + let run = run_turn( + &services_profile("files"), + context, + "files".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + assert!( + run.outcome.expect("file callbacks").succeeded(), + "{}", + run.stderr + ); +} +#[tokio::test] +async fn acp_client_services_terminal_output_exit_release_and_host_policy() { + for mode in ["terminal", "release", "shutdown", "wait_timeout"] { + let root = tempfile::tempdir().expect("root"); + let run = run_turn( + &services_profile(mode), + services_context(root.path()), + "terminal".into(), + CancellationToken::new(), + None, + ClientLimits { + callback_timeout: Duration::from_millis(500), + ..services_limits() + }, + ) + .await + .expect("launch"); + assert!( + run.outcome.expect("terminal callbacks").succeeded(), + "{}", + run.stderr + ); + assert!(run.process_reaped); + } +} +#[tokio::test] +async fn acp_client_services_cancel_reaps_terminal_without_blocking_dispatch() { + let root = tempfile::tempdir().expect("root"); + let context = services_context(root.path()); + let marker = context.issue_workspace.join("terminal.pid"); + let cancel = CancellationToken::new(); + let cancellation = cancel.clone(); + let cancelling = async { + tokio::time::timeout(Duration::from_secs(5), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("terminal started"); + tokio::time::sleep(Duration::from_millis(100)).await; + cancellation.cancel(); + }; + let config = services_profile("cancel"); + let (run, ()) = tokio::join!( + run_turn( + &config, + context, + "cancel".into(), + cancel, + None, + services_limits() + ), + cancelling + ); + let run = run.expect("launch"); + assert!( + run.outcome.expect("cancelled").cancellation_acknowledged, + "{}", + run.stderr + ); + assert!(run.process_reaped); + #[cfg(unix)] + { + let pid = std::fs::read_to_string(marker) + .expect("pid") + .parse::() + .expect("pid"); + assert!( + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .expect("probe") + .status + .code() + .is_some_and(|code| code != 0) + ); + } +} +#[tokio::test] +async fn acp_client_services_absence_and_session_configuration_are_negotiated() { + for mode in ["disabled", "config", "legacy_mode", "unsupported_config"] { + let root = tempfile::tempdir().expect("root"); + let mut profile = services_profile(mode); + if mode == "config" { + profile.session.model = Some("second".into()); + } + if mode == "unsupported_config" { + profile.session.model = Some("unknown".into()); + } + if mode == "legacy_mode" { + profile.session.mode = Some("code".into()); + } + let run = run_turn( + &profile, + context(root.path()), + "configuration".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + if mode == "unsupported_config" { + assert!(matches!(run.outcome, Err(ClientError::Setup(_)))); + assert!( + !run.evidence + .iter() + .any(|f| f.payload["method"] == "session/prompt") + ); + } else { + let report = run.outcome.expect("configured"); + assert!(report.succeeded(), "{}", run.stderr); + if mode == "config" { + assert_eq!(report.configuration.options[0]["currentValue"], "first"); + } + if mode == "legacy_mode" { + assert_eq!(report.configuration.current_mode.as_deref(), Some("ask")); + } + } + } +} +#[tokio::test] +async fn acp_scoped_mcp_attachment_requires_negotiated_transport_and_redacts_grants() { + use agent_client_protocol::schema::v1::{HttpHeader, McpServer, McpServerHttp}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listen"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("peer MCP access"); + let mut data = vec![0; 4096]; + let size = socket.read(&mut data).await.expect("request"); + assert!( + String::from_utf8_lossy(&data[..size]) + .contains("Authorization: Bearer scoped-grant-610") + ); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nCOE-610") + .await + .expect("response"); + }); + for mode in ["missing_mcp", "mcp"] { + let root = tempfile::tempdir().expect("root"); + let mut context = context(root.path()); + context.services.mcp_servers.push(McpServer::Http( + McpServerHttp::new("opensymphony-memory", format!("http://{address}/mcp")).headers( + vec![ + HttpHeader::new("Authorization", "Bearer scoped-grant-610"), + HttpHeader::new("X-Debug", "3"), + ], + ), + )); + let run = run_turn( + &services_profile(mode), + context, + "memory".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + if mode == "missing_mcp" { + assert!(matches!(run.outcome, Err(ClientError::Setup(_)))); + } else { + assert!( + run.outcome.expect("MCP attached").succeeded(), + "{}", + run.stderr + ); + } + assert!( + !serde_json::to_string(&run.evidence) + .expect("evidence") + .contains("scoped-grant-610") + ); + } + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("MCP accessed") + .expect("server"); +} + +#[tokio::test] +async fn acp_mcp_credential_arguments_are_redacted_from_requests_echoes_and_stderr() { + use agent_client_protocol::schema::v1::{McpServer, McpServerStdio}; + let root = tempfile::tempdir().expect("root"); + let mut context = context(root.path()); + context.services.mcp_servers.push(McpServer::Stdio( + McpServerStdio::new("memory", "memory-server").args(vec![ + "--token".into(), + "standalone-oauth-value".into(), + "--api-key=inline-api-value".into(), + "--header".into(), + "Authorization: Bearer generic-header-value".into(), + "--custom".into(), + "opaque-generic-arg".into(), + ]), + )); + let run = run_turn( + &services_profile("mcp_argv"), + context, + "memory".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + assert!( + run.outcome.expect("stdio attachment").succeeded(), + "{}", + run.stderr + ); + let evidence = serde_json::to_string(&run.evidence).expect("evidence"); + for secret in [ + "standalone-oauth-value", + "inline-api-value", + "generic-header-value", + ] { + assert!(!evidence.contains(secret)); + assert!(!run.stderr.contains(secret)); + } + assert!(!evidence.contains("opaque-generic-arg")); + assert!(evidence.contains("[redacted]")); +} + +#[tokio::test] +async fn acp_one_turn_rejects_write_adjacent_to_prompt_response() { + let root = tempfile::tempdir().expect("root"); + let context = services_context(root.path()); + let marker = context.issue_workspace.join("late-written"); + let run = run_turn( + &services_profile("late_after_prompt"), + context, + "finish".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + assert!( + run.outcome.expect("completed").succeeded(), + "{}", + run.stderr + ); + assert!( + !marker.exists(), + "post-response callback wrote into workspace" + ); +} + +#[test] +fn acp_one_turn_cancels_callback_pending_when_prompt_response_arrives() { + let runtime = tokio::runtime::Builder::new_current_thread() + .max_blocking_threads(1) + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async { + let root = tempfile::tempdir().expect("root"); + let context = services_context(root.path()); + let marker = context.issue_workspace.join("late-written"); + let response_sent = context.issue_workspace.join("response-sent"); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let blocker = tokio::task::spawn_blocking(move || { + let _ = started_tx.send(()); + release_rx.recv().expect("release filesystem worker"); + }); + started_rx.await.expect("filesystem worker occupied"); + let run = tokio::spawn(async move { + run_turn( + &services_profile("pending_at_response"), + context, + "finish".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + }); + let sent = tokio::time::timeout(Duration::from_secs(3), async { + while !response_sent.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await; + tokio::time::sleep(Duration::from_millis(50)).await; + release_tx.send(()).expect("release worker"); + blocker.await.expect("blocking worker"); + sent.expect("peer sent adjacent response"); + let run = run.await.expect("run task").expect("launch"); + assert!( + run.outcome.expect("completed").succeeded(), + "{}", + run.stderr + ); + assert!(!marker.exists(), "callback committed after prompt response"); + }); +} + +#[tokio::test] +async fn acp_client_services_callback_admission_bounds_pending_waits() { + let root = tempfile::tempdir().expect("root"); + let run = run_turn( + &services_profile("wait_flood"), + services_context(root.path()), + "flood".into(), + CancellationToken::new(), + None, + ClientLimits { + pending_callbacks: 2, + ..services_limits() + }, + ) + .await + .expect("launch"); + assert!(matches!( + run.outcome, + Err(ClientError::ResourceLimit { submitted: true }) + )); + assert!(run.process_reaped); +} +#[tokio::test] +async fn acp_client_services_handles_are_connection_scoped_even_with_equal_session_ids() { + let first_root = tempfile::tempdir().expect("first"); + let first = services_context(first_root.path()); + let marker = first.issue_workspace.join("terminal.id"); + let cancel = CancellationToken::new(); + let cancellation = cancel.clone(); + let owner_profile = services_profile("owner"); + let other = async { + let id = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(id) = tokio::fs::read_to_string(&marker).await + && !id.is_empty() + { + break id; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("owner terminal"); + let second_root = tempfile::tempdir().expect("second"); + let mut second = services_context(second_root.path()); + second.environment.insert("OTHER_TERMINAL".into(), id); + let result = run_turn( + &services_profile("foreign_handle"), + second, + "foreign".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("second launch"); + cancellation.cancel(); + assert!( + result.outcome.expect("foreign denied").succeeded(), + "{}", + result.stderr + ); + }; + let (run, ()) = tokio::join!( + run_turn( + &owner_profile, + first, + "owner".into(), + cancel, + None, + services_limits() + ), + other + ); + assert!( + run.expect("owner launch") + .outcome + .expect("owner cancelled") + .cancellation_acknowledged + ); +} + +#[test] +fn acp_explicit_session_profile_selections_are_bounded_and_round_trip() { + let mut profile = services_profile("config"); + profile.session.model = Some("provider/model:revision".into()); + profile + .session + .options + .insert("reasoning".into(), "high".into()); + profile.validate().expect("opaque selections"); + let encoded = serde_yaml::to_string(&profile).expect("serialize"); + let decoded: AcpProfile = serde_yaml::from_str(&encoded).expect("deserialize"); + assert_eq!(decoded.session, profile.session); + profile.session.model = Some(String::new()); + assert!(profile.validate().is_err()); + profile.session.model = Some("x".repeat(1025)); + assert!(profile.validate().is_err()); + profile.session.model = None; + profile.session.options = (0..33) + .map(|i| (format!("option-{i}"), "value".into())) + .collect(); + assert!(profile.validate().is_err()); +} + +#[tokio::test] +async fn acp_configuration_applies_model_before_dependent_options() { + let root = tempfile::tempdir().expect("root"); + let mut profile = services_profile("config_dependent"); + profile.session.model = Some("second".into()); + profile + .session + .options + .insert("a-reasoning".into(), "high".into()); + let run = run_turn( + &profile, + context(root.path()), + "configured".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + let report = run.outcome.expect("dependent selection applied"); + assert!(report.succeeded(), "{}", run.stderr); + assert!( + report + .configuration + .options + .iter() + .any(|option| option["id"] == "a-reasoning" && option["currentValue"] == "high") + ); +} + +#[tokio::test] +async fn acp_configuration_rpc_errors_preserve_classification_and_redact_context() { + for mode in ["config_auth_error", "config_rpc_error", "legacy_rpc_error"] { + let root = tempfile::tempdir().expect("root"); + let mut profile = services_profile(mode); + let mut context = context(root.path()); + context + .environment + .insert("TEST_TOKEN".into(), "rpc-secret-610".into()); + if mode.starts_with("legacy") { + profile.session.mode = Some("code".into()); + } else { + profile.session.model = Some("second".into()); + } + let run = run_turn( + &profile, + context, + "unsubmitted".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + let error = run.outcome.expect_err("configuration RPC failed"); + if mode == "config_auth_error" { + assert_eq!( + error, + ClientError::AuthenticationRequired { submitted: false } + ); + } else { + let ClientError::Rpc { + method, + code, + message, + submitted, + } = error + else { + panic!("expected contextual RPC error: {error:?}"); + }; + assert_eq!( + method, + if mode.starts_with("legacy") { + "session/set_mode" + } else { + "session/set_config_option" + } + ); + assert_eq!(code, -32603); + assert!(!submitted); + assert!(message.contains("unavailable")); + assert!(!message.contains("rpc-secret-610")); + } + } +} + +#[tokio::test] +async fn acp_configuration_resolves_mode_category_after_model_prerequisite() { + let root = tempfile::tempdir().expect("root"); + let mut profile = services_profile("config_dependent_mode"); + profile.session.model = Some("second".into()); + profile.session.mode = Some("code".into()); + let run = run_turn( + &profile, + context(root.path()), + "configured".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + let report = run.outcome.expect("dependent mode applied"); + assert!(report.succeeded(), "{}", run.stderr); + assert!( + report + .configuration + .options + .iter() + .any(|option| option["category"] == "mode" && option["currentValue"] == "code") + ); +} + +#[tokio::test] +async fn acp_mcp_urls_reject_embedded_credentials_before_launch() { + use agent_client_protocol::schema::v1::{McpServer, McpServerHttp, McpServerSse}; + for url in [ + "https://user:password@example.com/mcp", + "https://example.com/mcp?access_token=url-secret", + "https://example.com/mcp?api%5fkey=url-secret", + "https://example.com/mcp?oauth=url-secret", + "https://example.com/mcp?bearer=url-secret", + "https://example.com/mcp?key=url-secret", + "https://example.com/mcp?signature=url-secret", + "https://example.com/mcp?ordinary=query-value", + "https://example.com/mcp?", + "https://example.com/mcp#url-secret", + ] { + for sse in [false, true] { + let root = tempfile::tempdir().expect("root"); + let mut context = context(root.path()); + context.services.mcp_servers.push(if sse { + McpServer::Sse(McpServerSse::new("memory", url)) + } else { + McpServer::Http(McpServerHttp::new("memory", url)) + }); + let error = run_turn( + &services_profile("mcp"), + context, + "unused".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect_err("URL rejected before source capture"); + assert!(matches!(error, ClientError::InvalidConfiguration(_))); + assert!(!error.to_string().contains("url-secret")); + } + } +} + +#[tokio::test] +async fn acp_filesystem_payloads_stay_on_wire_and_out_of_evidence() { + let root = tempfile::tempdir().expect("root"); + let context = services_context(root.path()); + std::fs::write( + context.issue_workspace.join("private-file"), + "opaque_workspace_payload_610", + ) + .expect("private file"); + let run = run_turn( + &services_profile("file_privacy"), + context, + "files".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect("launch"); + assert!( + run.outcome + .expect("peer received original content") + .succeeded() + ); + let evidence = serde_json::to_string(&run.evidence).expect("evidence"); + assert!(!evidence.contains("opaque_workspace_payload_610")); + assert!( + run.evidence + .iter() + .any(|frame| frame.payload["result"]["content"] == "[redacted]") + ); + assert!( + run.evidence + .iter() + .any(|frame| frame.payload["method"] == "fs/write_text_file" + && frame.payload["params"]["content"] == "[redacted]") + ); + assert!( + run.evidence + .iter() + .any(|frame| frame.payload["result"]["output"] == "[redacted]") + ); +} + +#[tokio::test] +async fn acp_mcp_aliases_cannot_forward_resolved_excluded_checkout_values() { + use agent_client_protocol::schema::v1::{ + EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, + }; + for server in [ + McpServer::Stdio(McpServerStdio::new("memory", "memory-server").env(vec![ + EnvVariable::new("ACCESS_TOKEN", "checkout-secret-value"), + ])), + McpServer::Stdio( + McpServerStdio::new("memory", "memory-server") + .args(vec!["--token=checkout-secret-value".into()]), + ), + McpServer::Http( + McpServerHttp::new("memory", "https://example.test/mcp").headers(vec![ + HttpHeader::new("Authorization", "Bearer checkout-secret-value"), + ]), + ), + McpServer::Sse( + McpServerSse::new("memory", "https://example.test/mcp") + .headers(vec![HttpHeader::new("X-Grant", "checkout-secret-value")]), + ), + ] { + let root = tempfile::tempdir().expect("root"); + let mut context = context(root.path()); + context.services.mcp_servers.push(server); + let error = run_turn( + &services_profile("mcp"), + context, + "unused".into(), + CancellationToken::new(), + None, + services_limits(), + ) + .await + .expect_err("excluded value rejected before launch"); + assert!(matches!(error, ClientError::InvalidConfiguration(_))); + assert!(!error.to_string().contains("checkout-secret-value")); + } +} + +#[tokio::test] +async fn acp_encoded_facility_output_respects_small_response_budgets() { + let root = tempfile::tempdir().expect("root"); + let context = services_context(root.path()); + std::fs::write(context.issue_workspace.join("escaped"), vec![0; 2048]).expect("escaped"); + std::fs::write(context.issue_workspace.join("small"), "ok").expect("small"); + let run = run_turn( + &services_profile("response_budget"), + context, + "bounded".into(), + CancellationToken::new(), + None, + ClientLimits { + file_bytes: 4096, + terminal_output_bytes: 4096, + frame_bytes: 1024, + callback_bytes: 1024, + ..services_limits() + }, + ) + .await + .expect("launch"); + assert!( + run.outcome + .expect("oversized callbacks do not tear down transport") + .succeeded(), + "{}", + run.stderr + ); + assert!(run.process_reaped); +} diff --git a/tests/acp_session_host.rs b/tests/acp_session_host.rs index dcb8bc160..1bd29ab5a 100644 --- a/tests/acp_session_host.rs +++ b/tests/acp_session_host.rs @@ -44,6 +44,7 @@ async fn launch(root: &Path, issue: &str, mode: &str) -> SessionLaunch { issue_workspace: workspace.workspace_path().into(), environment: BTreeMap::from([("PATH".into(), std::env::var("PATH").expect("PATH"))]), excluded_environment: BTreeSet::new(), + services: Default::default(), }, manager, workspace, @@ -63,6 +64,7 @@ async fn launch(root: &Path, issue: &str, mode: &str) -> SessionLaunch { auth: None, required_capabilities: vec![], extensions: vec![], + session: Default::default(), }, limits: ClientLimits { setup_timeout: Duration::from_secs(3), @@ -169,13 +171,93 @@ async fn concurrent_sessions_busy_prompt_fence_and_cancellation_keep_other_comma retire(&b).await; } +#[test] +fn cancellation_while_submission_sync_is_blocked_never_sends_prompt() { + let runtime = tokio::runtime::Builder::new_current_thread() + .max_blocking_threads(1) + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let request = launch(root.path(), "SUBMIT", "none").await; + let workspace = request.workspace.workspace_path().to_path_buf(); + let handle = host.open(request).await.expect("open"); + + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let blocker = tokio::task::spawn_blocking(move || { + let _ = started_tx.send(()); + release_rx + .recv() + .expect("release blocking filesystem worker"); + }); + started_rx.await.expect("filesystem worker occupied"); + + let cancellation = CancellationToken::new(); + let pending = tokio::spawn({ + let handle = handle.clone(); + let cancellation = cancellation.clone(); + async move { + handle + .prompt( + "barrier-run".into(), + 1, + "must-not-send".into(), + cancellation, + ) + .await + } + }); + let barrier = tokio::time::timeout(Duration::from_secs(3), async { + loop { + // Preparation remains command-responsive. A stalled inspect + // identifies the actor awaiting the durable filesystem write. + if tokio::time::timeout(Duration::from_millis(20), handle.inspect()) + .await + .is_err() + { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await; + cancellation.cancel(); + release_tx.send(()).expect("release worker"); + blocker.await.expect("blocking worker"); + barrier.expect("submission reached filesystem barrier"); + let error = pending.await.expect("prompt task").expect_err("cancelled"); + assert!( + error + .to_string() + .contains("cancelled before prompt submission") + ); + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(workspace.join(".opensymphony/conversation.json")).expect("manifest"), + ) + .expect("manifest JSON"); + assert_eq!(manifest["acp"]["status"], "finished"); + assert_eq!(manifest["acp"]["stop_reason"], "cancelled_before_prompt"); + assert!( + !std::fs::read_to_string(workspace.join("methods")) + .expect("peer method log") + .contains("session/prompt"), + "cancelled prompt crossed the transport barrier" + ); + }); +} + #[tokio::test] async fn retention_leases_expire_and_resource_limits_refuse_active_owners() { let root = tempfile::tempdir().expect("temp"); let host = SessionHost::new(RetentionPolicy { max_sessions: 1, idle_timeout: Duration::from_millis(100), - attachment_ttl: Duration::from_millis(300), + // Leave room for parallel workspace setup before the renewal; expiry + // is asserted after the full lease interval below. + attachment_ttl: Duration::from_secs(2), ..RetentionPolicy::default() }) .expect("host"); @@ -198,7 +280,7 @@ async fn retention_leases_expire_and_resource_limits_refuse_active_owners() { a.control(SessionControl::Renew { lease_id }) .await .expect("renew"); - tokio::time::sleep(Duration::from_millis(380)).await; + tokio::time::sleep(Duration::from_millis(2200)).await; assert!( a.inspect().await.is_err(), "expired attachment allows visible retirement" @@ -618,7 +700,9 @@ async fn recorded_inspection_and_bounded_source_history_are_truthful() { .expect("recorded"); assert!(!recorded.live && !recorded.retirement_eligible); assert_eq!(recorded.state.recovery, AcpRecovery::TranscriptOnly); - let mut bounded = launch(root.path(), "ISSUE-1", "none").await; + // Changed limits identify a different owner; exercise bounded history on + // a fresh workspace rather than changing the recorded owner contract. + let mut bounded = launch(root.path(), "BOUNDED", "none").await; bounded.limits.queued_frames = 2; bounded.limits.queued_bytes = 256; let bounded = host.open(bounded).await.expect("bounded owner"); @@ -667,3 +751,512 @@ async fn missing_persisted_context_resets_only_when_persistence_is_optional() { .contains("persisted context") ); } + +#[tokio::test] +async fn missing_restoration_revokes_old_callbacks_before_fresh_session_response() { + use agent_client_protocol::schema::v1::{McpServer, McpServerStdio}; + for mode in ["services_load", "services_resume"] { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + for attempt in 0..2 { + let mut request = launch(root.path(), "RESET", mode).await; + request.context.services.write_files = true; + request.context.services.mcp_servers.push(McpServer::Stdio( + McpServerStdio::new("memory", "memory-server").args(vec![ + "--token".into(), + "retained-scope-grant".into(), + "--custom".into(), + "opaque-generic-arg".into(), + ]), + )); + let handle = host.open(request).await.expect("open or reset"); + assert!( + prompt(&handle, &format!("attempt-{attempt}"), "hello") + .await + .succeeded() + ); + retire(&handle).await; + std::fs::write(root.path().join("RESET/forget-session"), "").expect("forget"); + } + assert!( + root.path() + .join("RESET/abandoned-callback-rejected") + .exists() + ); + assert!(!root.path().join("RESET/abandoned-write").exists()); + } +} + +async fn configured_launch(root: &Path) -> SessionLaunch { + let mut request = launch(root, "CONFIG", "services_config").await; + request.profile.session.model = Some("second".into()); + request.profile.session.mode = Some("execute".into()); + request + .profile + .session + .options + .insert("verbosity".into(), "loud".into()); + request +} + +async fn ended(events: &mut tokio::sync::broadcast::Receiver) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match events.recv().await { + Ok(SessionEvent::Ended { .. }) => break, + Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(error) => panic!("owner closed before terminal event: {error}"), + } + } + }) + .await + .expect("owner ended"); +} + +#[tokio::test] +async fn retained_preparation_reapplies_explicit_choices_and_rejects_removed_choices() { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(configured_launch(root.path()).await) + .await + .expect("open"); + let mut events = handle.subscribe(); + let drifted = prompt(&handle, "drift", "config-drift").await; + assert_eq!(drifted.configuration.options[0]["currentValue"], "first"); + let configured = prompt(&handle, "configured", "config-assert").await; + for (option, expected) in configured + .configuration + .options + .iter() + .zip(["second", "execute", "loud"]) + { + assert_eq!(option["currentValue"], expected); + } + let history = handle.source_history(); + let configured_ids = history + .events + .iter() + .filter_map(|event| match event { + SessionEvent::Source { run_id, frame, .. } + if frame.payload["method"] == "session/set_config_option" + && run_id == "configured" => + { + Some(frame.payload["id"].to_string()) + } + _ => None, + }) + .collect::>(); + assert_eq!( + configured_ids.len(), + 3, + "model/mode/option preparation belongs to pending run" + ); + for event in &history.events { + if let SessionEvent::Source { run_id, frame, .. } = event + && frame.direction == "incoming" + && configured_ids.contains(&frame.payload["id"].to_string()) + { + assert_eq!(run_id, "configured", "configuration response attribution"); + } + } + prompt(&handle, "remove", "config-remove").await; + assert!( + handle + .prompt( + "must-not-submit".into(), + 1, + "config-assert".into(), + CancellationToken::new() + ) + .await + .is_err() + ); + ended(&mut events).await; + let manifest: ConversationManifest = serde_json::from_slice( + &std::fs::read(root.path().join("CONFIG/.opensymphony/conversation.json")) + .expect("manifest"), + ) + .expect("JSON"); + assert_eq!(manifest.acp.expect("ACP").identity.run_id, "remove"); + assert_eq!( + std::fs::read_to_string(root.path().join("CONFIG/methods")) + .expect("methods") + .lines() + .filter(|method| *method == "session/prompt") + .count(), + 3 + ); +} + +#[tokio::test] +async fn retained_configuration_preparation_remains_cancellable_and_deadline_bounded() { + for cancel in [false, true] { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = configured_launch(root.path()).await; + request.limits.setup_timeout = Duration::from_secs(2); + let handle = host.open(request).await.expect("open"); + let mut events = handle.subscribe(); + prompt(&handle, "stall", "config-stall").await; + let cancellation = CancellationToken::new(); + let marker = root.path().join("CONFIG/preparing-config"); + let (result, ()) = tokio::join!( + handle.prompt( + "must-not-submit".into(), + 1, + "config-assert".into(), + cancellation.clone() + ), + async { + tokio::time::timeout(Duration::from_secs(3), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("preparation started"); + let state = handle.inspect().await.expect("owner stays responsive"); + assert_eq!(state.state.status, AcpSessionStatus::Finished); + assert!(handle.source_history().events.iter().any(|event| matches!(event, SessionEvent::Source {run_id, frame, ..} if run_id == "must-not-submit" && frame.payload["method"] == "session/set_config_option")), "preparation attribution does not depend on later submission"); + if cancel { + cancellation.cancel(); + } + } + ); + let error = result.expect_err("preparation must stop").to_string(); + assert!( + error.contains(if cancel { "cancel" } else { "deadline" }), + "{error}" + ); + ended(&mut events).await; + let manifest: ConversationManifest = serde_json::from_slice( + &std::fs::read(root.path().join("CONFIG/.opensymphony/conversation.json")) + .expect("manifest"), + ) + .expect("JSON"); + assert_eq!(manifest.acp.expect("ACP").identity.run_id, "stall"); + } +} + +#[tokio::test] +async fn retained_callbacks_reset_after_cancellation_and_keep_live_configuration() { + let root = tempfile::tempdir().expect("root"); + let mut launch = launch(root.path(), "SERVICES", "services").await; + launch.context.services = HostServices { + read_files: true, + write_files: true, + terminals: true, + ..Default::default() + }; + launch.profile.session.model = Some("second".into()); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host.open(launch).await.expect("open"); + let cancellation = CancellationToken::new(); + let cancel = cancellation.clone(); + let marker = root.path().join("SERVICES/callback.pid"); + let (first, ()) = tokio::join!( + handle.prompt( + "cancel-turn".into(), + 1, + "services-cancel".into(), + cancellation + ), + async { + tokio::time::timeout(Duration::from_secs(5), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("terminal started"); + cancel.cancel(); + } + ); + let first = first.expect("cancel acknowledgement"); + assert!(first.cancellation_acknowledged); + assert_eq!(first.configuration.options[0]["currentValue"], "second"); + let second = prompt(&handle, "next-turn", "services-next").await; + assert!(second.succeeded()); + assert_eq!(first.session_id, second.session_id); + assert_eq!(second.configuration.options[0]["currentValue"], "first"); + assert_eq!( + std::fs::read_to_string(root.path().join("SERVICES/launches")) + .expect("launches") + .lines() + .count(), + 1 + ); + retire(&handle).await; +} + +#[tokio::test] +async fn retained_identity_fingerprints_host_policy_and_resolved_mcp_grants() { + use agent_client_protocol::schema::v1::{McpServer, McpServerStdio}; + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let server = |token: &str| { + McpServer::Stdio( + McpServerStdio::new("memory", "memory-server") + .args(vec!["--token".into(), token.into()]), + ) + }; + let mut first = launch(root.path(), "GRANTS", "none").await; + first.context.services.mcp_servers = vec![server("original-scoped-grant")]; + let handle = host.open(first).await.expect("open"); + for changed_policy in [true, false] { + let mut changed = launch(root.path(), "GRANTS", "none").await; + changed.context.services.read_files = changed_policy; + changed.context.services.mcp_servers = vec![server(if changed_policy { + "original-scoped-grant" + } else { + "replacement-scoped-grant" + })]; + assert!(matches!( + host.open(changed).await, + Err(HostError::IdentityMismatch) + )); + } + let manifest = + std::fs::read_to_string(root.path().join("GRANTS/.opensymphony/conversation.json")) + .expect("manifest"); + assert!(!manifest.contains("original-scoped-grant")); + assert!(!manifest.contains("replacement-scoped-grant")); + retire(&handle).await; +} + +#[tokio::test] +async fn restored_sessions_receive_scoped_mcp_and_apply_returned_configuration() { + use agent_client_protocol::schema::v1::{McpServer, McpServerStdio}; + for mode in ["services_load", "services_resume"] { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut session_id = None; + for attempt in 0..2 { + let mut launch = launch(root.path(), "RESTORE", mode).await; + launch.profile.session.model = Some("second".into()); + launch.context.services.mcp_servers.push(McpServer::Stdio( + McpServerStdio::new("memory", "memory-server").args(vec![ + "--token".into(), + "retained-scope-grant".into(), + "--custom".into(), + "opaque-generic-arg".into(), + ]), + )); + let handle = host.open(launch).await.expect("open or restore"); + let history = handle.source_history(); + let mut scoped_requests = 0; + for event in &history.events { + if let SessionEvent::Source { frame, .. } = event + && matches!( + frame.payload["method"].as_str(), + Some("session/new" | "session/load" | "session/resume") + ) + { + scoped_requests += 1; + let captured = frame.payload.to_string(); + assert!(!captured.contains("opaque-generic-arg")); + assert!(!captured.contains("retained-scope-grant")); + assert_eq!( + frame.payload["params"]["mcpServers"][0]["args"], + serde_json::json!(["[redacted]", "[redacted]", "[redacted]", "[redacted]"]) + ); + } + } + assert!(scoped_requests > 0, "source history includes MCP setup"); + let report = prompt(&handle, &format!("attempt-{attempt}"), "hello").await; + assert_eq!(report.configuration.options[0]["currentValue"], "second"); + if let Some(id) = &session_id { + assert_eq!(&report.session_id, id); + } else { + session_id = Some(report.session_id); + } + retire(&handle).await; + } + let methods = + std::fs::read_to_string(root.path().join("RESTORE/methods")).expect("methods"); + assert!(methods.contains(if mode == "services_load" { + "session/load" + } else { + "session/resume" + })); + } +} + +#[tokio::test] +async fn retained_identity_rejects_changed_callback_and_response_limits() { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(launch(root.path(), "LIMITS", "none").await) + .await + .expect("open"); + for kind in 0..8 { + let mut changed = launch(root.path(), "LIMITS", "none").await; + match kind { + 0 => changed.limits.file_bytes = 1024, + 1 => changed.limits.terminal_output_bytes = 1024, + 2 => changed.limits.terminal_count = 1, + 3 => changed.limits.pending_callbacks = 1, + 4 => changed.limits.callback_timeout = Duration::from_secs(1), + 5 => changed.limits.frame_bytes = 4096, + 6 => changed.limits.callback_bytes = 4096, + _ => changed.limits.callback_frames = 1, + } + assert!( + matches!(host.open(changed).await, Err(HostError::IdentityMismatch)), + "kind {kind}" + ); + } + retire(&handle).await; +} + +#[tokio::test] +async fn ordinary_mcp_environment_does_not_poison_launch_or_retained_source_values() { + use agent_client_protocol::schema::v1::{EnvVariable, McpServer, McpServerStdio}; + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "MCPENV", "none").await; + request.context.services.mcp_servers.push(McpServer::Stdio( + McpServerStdio::new("memory", "memory-server").env(vec![ + EnvVariable::new("DEBUG", "3"), + EnvVariable::new("ACCESS_TOKEN", "retained-env-secret"), + ]), + )); + let handle = host + .open(request) + .await + .expect("DEBUG=3 must not reject python3"); + prompt(&handle, "env", "debug 3 retained-env-secret").await; + let history = handle.source_history(); + let request = history + .events + .iter() + .find_map(|event| match event { + SessionEvent::Source { frame, .. } if frame.payload["method"] == "session/new" => { + Some(&frame.payload) + } + _ => None, + }) + .expect("source request"); + assert_eq!( + request["params"]["mcpServers"][0]["env"][0]["value"], + "[redacted]" + ); + assert!(history.events.iter().any(|event| matches!(event, SessionEvent::Source { frame, .. } if frame.payload["params"]["update"]["content"]["text"] == "debug 3 [redacted]"))); + assert!( + !serde_json::to_string(&history.events) + .expect("source") + .contains("retained-env-secret") + ); + retire(&handle).await; +} + +#[tokio::test] +async fn retained_source_redacts_file_payloads_without_changing_callback_wire_content() { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "PRIVATE", "services").await; + request.context.services.read_files = true; + request.context.services.write_files = true; + request.context.services.terminals = true; + std::fs::write( + request.workspace.workspace_path().join("private-file"), + "opaque_workspace_payload_610", + ) + .expect("private file"); + let handle = host.open(request).await.expect("open"); + assert!(prompt(&handle, "private", "file-privacy").await.succeeded()); + assert_eq!( + std::fs::read_to_string(root.path().join("PRIVATE/private-copy")) + .expect("peer received and wrote original"), + "opaque_workspace_payload_610" + ); + let history = handle.source_history(); + assert!( + !serde_json::to_string(&history.events) + .expect("history") + .contains("opaque_workspace_payload_610") + ); + assert!(history.events.iter().any(|event| matches!(event, SessionEvent::Source {frame, ..} if frame.payload["result"]["content"] == "[redacted]"))); + assert!(history.events.iter().any(|event| matches!(event, SessionEvent::Source {frame, ..} if frame.payload["method"] == "fs/write_text_file" && frame.payload["params"]["content"] == "[redacted]"))); + assert!(history.events.iter().any(|event| matches!(event, SessionEvent::Source {frame, ..} if frame.payload["result"]["output"] == "[redacted]"))); + retire(&handle).await; +} + +#[tokio::test] +async fn retained_pre_prompt_callbacks_are_rejected_until_begin_turn() { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "BEFORE", "services_pre_prompt").await; + request.context.services.write_files = true; + request.context.services.terminals = true; + request.profile.session.model = Some("second".into()); + let handle = host.open(request).await.expect("open"); + tokio::time::timeout(Duration::from_secs(3), async { + while !root.path().join("BEFORE/pre-prompt-rejected").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("pre-prompt callbacks rejected"); + assert!(!root.path().join("BEFORE/pre-prompt-write").exists()); + assert!(!root.path().join("BEFORE/pre-prompt-process").exists()); + assert!(prompt(&handle, "first", "first").await.succeeded()); + retire(&handle).await; +} + +#[tokio::test] +async fn normal_retained_completion_reaps_callbacks_and_rejects_adjacent_idle_work() { + let root = tempfile::tempdir().expect("root"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "LATE", "services").await; + request.context.services = HostServices { + read_files: true, + write_files: true, + terminals: true, + ..Default::default() + }; + let handle = host.open(request).await.expect("open"); + let report = prompt(&handle, "normal", "late-callbacks").await; + assert!(report.succeeded()); + assert!( + !report.cancellation_requested, + "callback closure is separate from prompt cancellation" + ); + #[cfg(unix)] + { + let pid: i32 = std::fs::read_to_string(root.path().join("LATE/normal-child.pid")) + .expect("child") + .parse() + .expect("PID"); + assert_eq!( + rustix::process::test_kill_process(rustix::process::Pid::from_raw(pid).expect("PID")), + Err(rustix::io::Errno::SRCH), + "child must be reaped before completion reply" + ); + } + tokio::time::timeout(Duration::from_secs(3), async { + while !root.path().join("LATE/late-rejected").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("adjacent idle callbacks rejected"); + assert!(!root.path().join("LATE/late-write").exists()); + assert!(!root.path().join("LATE/late-process").exists()); + assert_eq!( + handle.inspect().await.expect("idle owner").state.status, + AcpSessionStatus::Finished + ); + // Repeated short-lived terminals exercise Darwin's exit/group-reap race + // while other retained-host cases run in parallel. + for attempt in 0..if cfg!(target_os = "macos") { 12 } else { 1 } { + assert!( + prompt(&handle, &format!("next-{attempt}"), "services-next") + .await + .succeeded() + ); + } + retire(&handle).await; +} diff --git a/tests/fixtures/acp_services_peer.py b/tests/fixtures/acp_services_peer.py new file mode 100644 index 000000000..9d6b2686b --- /dev/null +++ b/tests/fixtures/acp_services_peer.py @@ -0,0 +1,262 @@ +"""Executable ACP callback/configuration peer; assertions are part of the test oracle.""" +import json +import os +import sys +import time + +mode = sys.argv[1] +session = 'services-session' +serial = 100 +model_choice = "first" +reasoning = "low" +mode_choice = "ask" + + +def send(value): + print(json.dumps({'jsonrpc': '2.0', **value}), flush=True) + + +def receive(): + line = sys.stdin.readline() + if not line: + raise RuntimeError('client disconnected') + return json.loads(line) + + +def respond(message, result): + send({'id': message['id'], 'result': result}) + + +def request(method, params, expected_error=False): + global serial + serial += 1 + send({'id': serial, 'method': method, 'params': {'sessionId': session, **params}}) + response = receive() + assert response['id'] == serial, response + if expected_error: + assert 'error' in response, response + return response['error'] + assert 'result' in response, response + return response['result'] + + +def config(value='first'): + result = [{'id': 'pick', 'name': 'Model', 'category': 'model', 'type': 'select', + 'currentValue': value, 'options': [{'group': 'models', 'name': 'Models', 'options': [ + {'value': 'first', 'name': 'First'}, {'value': 'second', 'name': 'Second'}]}]}] + if mode == 'config_dependent': + result.append({'id': 'a-reasoning', 'name': 'Reasoning', 'type': 'select', 'currentValue': reasoning, + 'options': [{'value': v, 'name': v} for v in (['low', 'high'] if value == 'second' else ['low'])]}) + if mode == 'config_dependent_mode' and value == 'second': + result.append({'id': 'behavior', 'category': 'mode', 'name': 'Mode', 'type': 'select', 'currentValue': mode_choice, 'options': [{'value': v, 'name': v} for v in ['ask', 'code']]}) + return result + + +while True: + message = receive() + method = message.get('method') + if method == 'initialize': + caps = message['params']['clientCapabilities'] + enabled = not mode.startswith(('config', 'legacy')) and mode not in ('disabled', 'config', 'unsupported_config', 'legacy_mode', 'missing_mcp', 'mcp', 'mcp_argv') + assert caps.get('terminal', False) == enabled, caps + assert caps.get('fs', {}).get('readTextFile', False) == enabled, caps + assert caps.get('fs', {}).get('writeTextFile', False) == enabled, caps + assert not caps.get('auth', {}).get('terminal') + assert not caps.get('elicitation') + respond(message, {'protocolVersion': 1, 'agentCapabilities': {'mcpCapabilities': {'http': mode == 'mcp'}}}) + elif method == 'session/new': + assert message['params']['cwd'] == os.getcwd() + if mode == 'mcp': + servers = message['params']['mcpServers'] + assert len(servers) == 1 + assert servers[0]['type'] == 'http' + assert servers[0]['name'] == 'opensymphony-memory' + assert servers[0]['headers'] == [{'name': 'Authorization', 'value': 'Bearer scoped-grant-610'}, {'name': 'X-Debug', 'value': '3'}] + # The peer consumes the immutable host attachment, including its scoped grant. + import urllib.request + req = urllib.request.Request(servers[0]['url'], headers={'Authorization': servers[0]['headers'][0]['value']}) + with urllib.request.urlopen(req) as response: + assert response.read() == b'COE-610' + if mode == 'mcp_argv': + server = message['params']['mcpServers'][0] + assert server['args'] == ['--token', 'standalone-oauth-value', '--api-key=inline-api-value', + '--header', 'Authorization: Bearer generic-header-value', + '--custom', 'opaque-generic-arg'] + # Echoing a granted argument in a nonsensitive field or stderr must + # not persist the original value in source evidence. + print('standalone-oauth-value inline-api-value generic-header-value', file=sys.stderr, flush=True) + result = {'sessionId': session} + if mode.startswith('config') or mode == 'unsupported_config': + result['configOptions'] = config() + if mode.startswith('legacy'): + result['modes'] = {'currentModeId': 'ask', 'availableModes': [{'id': 'ask', 'name': 'Ask'}, {'id': 'code', 'name': 'Code'}]} + respond(message, result) + elif method == 'session/set_config_option': + if mode in ('config_auth_error', 'config_rpc_error'): + send({'id': message['id'], 'error': {'code': -32000 if mode == 'config_auth_error' else -32603, + 'message': 'configuration unavailable rpc-secret-610'}}) + continue + if mode in ('config_dependent', 'config_dependent_mode'): + if model_choice == 'first': + assert message['params'] == {'sessionId': session, 'configId': 'pick', 'value': 'second'} + model_choice = 'second' + elif mode == 'config_dependent_mode': + assert message['params'] == {'sessionId': session, 'configId': 'behavior', 'value': 'code'} + mode_choice = 'code' + else: + assert message['params'] == {'sessionId': session, 'configId': 'a-reasoning', 'value': 'high'} + reasoning = 'high' + respond(message, {'configOptions': config(model_choice)}) + else: + assert mode == 'config' + assert message['params'] == {'sessionId': session, 'configId': 'pick', 'value': 'second'} + respond(message, {'configOptions': config('second')}) + elif method == 'session/set_mode': + assert message['params'] == {'sessionId': session, 'modeId': 'code'} + if mode == 'legacy_rpc_error': + send({'id': message['id'], 'error': {'code': -32603, 'message': 'mode unavailable rpc-secret-610'}}) + else: + respond(message, {}) + elif method == 'session/prompt': + prompt_id = message['id'] + assert not mode.endswith('_error') + if mode == 'config_dependent_mode': + assert model_choice == 'second' and mode_choice == 'code' + if mode == 'config_dependent': + assert model_choice == 'second' and reasoning == 'high' + if mode == 'mcp_argv': + send({'method': 'session/update', 'params': {'sessionId': session, 'update': {'sessionUpdate': 'agent_message_chunk', 'content': {'type': 'text', 'text': 'standalone-oauth-value inline-api-value generic-header-value'}}}}) + elif mode == 'late_after_prompt': + frames = [ + {'jsonrpc': '2.0', 'id': prompt_id, 'result': {'stopReason': 'end_turn'}}, + {'jsonrpc': '2.0', 'id': 'late-write', 'method': 'fs/write_text_file', + 'params': {'sessionId': session, 'path': os.path.join(os.getcwd(), 'late-written'), 'content': 'escaped'}}, + ] + sys.stdout.write(''.join(json.dumps(frame) + '\n' for frame in frames)) + sys.stdout.flush() + try: + response = receive() + assert response['id'] == 'late-write' and 'error' in response, response + except RuntimeError: + pass # The one-turn host may close after revoking the callback. + continue + elif mode == 'pending_at_response': + send({'id': 'pending-write', 'method': 'fs/write_text_file', + 'params': {'sessionId': session, 'path': os.path.join(os.getcwd(), 'late-written'), + 'content': 'escaped'}}) + respond(message, {'stopReason': 'end_turn'}) + open('response-sent', 'w').close() + try: + response = receive() + assert response['id'] == 'pending-write' and 'error' in response, response + except RuntimeError: + pass + continue + elif mode in ('config', 'legacy_mode'): + update = {'sessionUpdate': 'config_option_update', 'configOptions': config('first')} if mode == 'config' else {'sessionUpdate': 'current_mode_update', 'currentModeId': 'ask'} + send({'method': 'session/update', 'params': {'sessionId': session, 'update': update}}) + elif mode == 'disabled': + assert request('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'file')}, True)['code'] == -32601 + assert request('terminal/create', {'command': sys.executable}, True)['code'] == -32601 + elif mode == 'file_privacy': + content = request('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'private-file')})['content'] + assert content == 'opaque_workspace_payload_610' + request('fs/write_text_file', {'path': os.path.join(os.getcwd(), 'private-copy'), 'content': content}) + terminal = request('terminal/create', {'command': sys.executable, 'args': ['-c', "import sys; sys.stdout.write(open('private-file').read())"]})['terminalId'] + assert request('terminal/wait_for_exit', {'terminalId': terminal})['exitCode'] == 0 + assert request('terminal/output', {'terminalId': terminal})['output'] == content + request('terminal/release', {'terminalId': terminal}) + elif mode == 'response_budget': + request('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'escaped')}, True) + assert request('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'small')})['content'] == 'ok' + terminal = request('terminal/create', {'command': sys.executable, 'args': ['-c', 'import sys; sys.stdout.write(chr(0)*2048)']})['terminalId'] + request('terminal/wait_for_exit', {'terminalId': terminal}) + output = request('terminal/output', {'terminalId': terminal}) + assert output['truncated'] and output['exitStatus']['exitCode'] == 0 + assert set(output['output']) <= {chr(0)} + request('terminal/release', {'terminalId': terminal}) + elif mode == 'files': + path = os.path.join(os.getcwd(), 'new', 'nested', 'file') + text = 'uno\r\ndos 😀\nlast' + request('fs/write_text_file', {'path': path, 'content': text}) + assert request('fs/read_text_file', {'path': path})['content'] == text + assert request('fs/read_text_file', {'path': path, 'line': 2, 'limit': 1})['content'] == 'dos 😀\n' + assert request('fs/read_text_file', {'path': path, 'line': 99})['content'] == '' + assert request('fs/read_text_file', {'path': path, 'limit': 0})['content'] == '' + for invalid in [{'line': 0}, {'line': -1}, {'line': '2'}, {'limit': -1}]: + request('fs/read_text_file', {'path': path, **invalid}, True) + request('fs/read_text_file', {'sessionId': 'foreign', 'path': path}, True) + request('fs/write_text_file', {'sessionId': 'foreign', 'path': path, 'content': 'wrong'}, True) + bad_paths = ['relative', os.path.join(os.getcwd(), '..', 'escape'), os.path.join(os.path.dirname(os.getcwd()), 'nonexistent', 'file')] + if os.name != 'nt': + bad_paths += [os.path.join(os.getcwd(), 'outside', 'new'), os.path.join(os.getcwd(), 'dangling')] + for bad in bad_paths: + request('fs/write_text_file', {'path': bad, 'content': 'escape'}, True) + request('fs/write_text_file', {'path': path, 'content': 'x' * 1025}, True) + request('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'oversized')}, True) + request('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'invalid-utf8')}, True) + assert request('fs/read_text_file', {'path': path})['content'] == text + elif mode in ('terminal', 'release', 'cancel', 'shutdown', 'wait_timeout', 'wait_flood', 'owner', 'foreign_handle'): + for params in [{'cwd': os.path.dirname(os.getcwd())}, {'env': [{'name': 'PATH', 'value': 'evil'}]}, {'env': [{'name': 'CHECKOUT_SECRET', 'value': 'stolen'}]}, {'outputByteLimit': 2049}, {'cwd': 42}, {'env': 'invalid'}]: + request('terminal/create', {'command': sys.executable, **params}, True) + # A late editor-like extension cannot replace host facilities or grants. + request('_ide/attach', {'cwd': '/', 'env': {'PATH': 'evil'}, 'mcpServers': []}, True) + if mode == 'foreign_handle': + request('terminal/output', {'terminalId': os.environ['OTHER_TERMINAL']}, True) + respond(message, {'stopReason': 'end_turn'}) + continue + if mode == 'terminal': + code = "import os,sys; assert os.environ['HOST_SCOPE']=='COE-610'; sys.stdout.buffer.write(('prefix-'+'😀'*5+'END').encode()); sys.stdout.flush(); sys.exit(7)" + terminal = request('terminal/create', {'command': sys.executable, 'args': ['-c', code], 'outputByteLimit': 10})['terminalId'] + request('terminal/output', {'sessionId': 'foreign', 'terminalId': terminal}, True) + assert request('terminal/wait_for_exit', {'terminalId': terminal})['exitCode'] == 7 + output = request('terminal/output', {'terminalId': terminal}) + assert output['output'] == '😀END', output + assert output['truncated'] and output['exitStatus']['exitCode'] == 7, output + assert request('terminal/wait_for_exit', {'terminalId': terminal})['exitCode'] == 7 + request('terminal/release', {'terminalId': terminal}) + request('terminal/output', {'terminalId': terminal}, True) + else: + code = "import os,time; open('terminal.pid','w').write(str(os.getpid())); time.sleep(60)" + terminal = request('terminal/create', {'command': sys.executable, 'args': ['-c', code]})['terminalId'] + while not os.path.exists('terminal.pid'): + time.sleep(0.01) + if mode == 'shutdown': + respond(message, {'stopReason': 'end_turn'}) + continue + if mode == 'owner': + with open('terminal.id', 'w') as marker: + marker.write(terminal) + while receive().get('method') != 'session/cancel': + pass + send({'id': prompt_id, 'result': {'stopReason': 'cancelled'}}) + continue + if mode == 'wait_timeout': + request('terminal/wait_for_exit', {'terminalId': terminal}, True) + request('terminal/kill', {'terminalId': terminal}) + assert request('terminal/output', {'terminalId': terminal})['exitStatus'] + request('terminal/release', {'terminalId': terminal}) + respond(message, {'stopReason': 'end_turn'}) + continue + if mode == 'wait_flood': + for index in range(256): + send({'id': index + 1000, 'method': 'terminal/wait_for_exit', 'params': {'sessionId': session, 'terminalId': terminal}}) + time.sleep(60) + send({'id': 'waiting', 'method': 'terminal/wait_for_exit', 'params': {'sessionId': session, 'terminalId': terminal}}) + send({'id': 'output', 'method': 'terminal/output', 'params': {'sessionId': session, 'terminalId': terminal}}) + assert receive()['id'] == 'output' # pending wait never blocks dispatch + if mode == 'cancel': + while True: + response = receive() + if response.get('method') == 'session/cancel': + send({'id': prompt_id, 'result': {'stopReason': 'cancelled'}}) + break + continue + send({'id': 'release', 'method': 'terminal/release', 'params': {'sessionId': session, 'terminalId': terminal}}) + responses = [receive(), receive()] + assert {r['id'] for r in responses} == {'waiting', 'release'} + assert all('result' in r for r in responses), responses + request('terminal/output', {'terminalId': terminal}, True) + respond(message, {'stopReason': 'end_turn'}) + else: + raise AssertionError(message) diff --git a/tests/fixtures/acp_session_peer.py b/tests/fixtures/acp_session_peer.py index ac4474a9a..381b22513 100644 --- a/tests/fixtures/acp_session_peer.py +++ b/tests/fixtures/acp_session_peer.py @@ -5,8 +5,17 @@ import uuid mode = sys.argv[1] +services_enabled = mode.startswith("services") +persistence = mode.removeprefix("services_") session = str(uuid.uuid4()) pending = None +terminal = None +model = "first" +session_mode = "plan" +verbosity = "quiet" +serial = 100 +abandoned_request = None +pending_new = None def send(payload): @@ -18,38 +27,163 @@ def update(text): "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}}}) +def config(): + models = ('first',) if os.path.exists('remove-choice') else ('first', 'second') + options = [{'id': 'model', 'name': 'Model', 'category': 'model', 'type': 'select', + 'currentValue': model, 'options': [{'value': v, 'name': v} for v in models]}] + if persistence == 'config': + options.extend([ + {'id': 'mode', 'name': 'Mode', 'category': 'mode', 'type': 'select', + 'currentValue': session_mode, 'options': [{'value': v, 'name': v} for v in ('plan', 'execute')]}, + {'id': 'verbosity', 'name': 'Verbosity', 'type': 'select', + 'currentValue': verbosity, 'options': [{'value': v, 'name': v} for v in ('quiet', 'loud')]}, + ]) + return options + + +def new_session(message): + assert message['params']['cwd'] == os.getcwd() + if services_enabled and persistence in ('load', 'resume'): + assert message['params']['mcpServers'][0]['args'] == ['--token', 'retained-scope-grant', + '--custom', 'opaque-generic-arg'] + result = {'sessionId': session} + if services_enabled: + result['configOptions'] = config() + send({'id': message['id'], 'result': result}) + + +def callback(method, params, error=False): + global serial + serial += 1 + send({'id': serial, 'method': method, 'params': {'sessionId': session, **params}}) + response = json.loads(sys.stdin.readline()) + assert response['id'] == serial, response + assert ('error' in response) == error, response + return response.get('result') + + with open("launches", "a") as log: log.write(str(os.getpid()) + "\n") for line in sys.stdin: message = json.loads(line) method = message.get("method") + if method is None and message.get('id') == abandoned_request: + assert 'error' in message, message + abandoned_request = None + with open('abandoned-callback-rejected', 'w') as log: + log.write('rejected') + if pending_new is not None: + new_session(pending_new) + pending_new = None + continue with open("methods", "a") as log: log.write(method + "\n") if method == "initialize": - caps = {"loadSession": mode == "load"} - if mode == "resume": + caps = {"loadSession": persistence == "load"} + if persistence == "resume": caps["sessionCapabilities"] = {"resume": {}} send({"id": message["id"], "result": {"protocolVersion": 1, "agentCapabilities": caps}}) elif method == "session/new": - assert message["params"]["cwd"] == os.getcwd() - send({"id": message["id"], "result": {"sessionId": session}}) + if abandoned_request is not None: + pending_new = message + else: + new_session(message) elif method in ("session/load", "session/resume"): - assert method == "session/" + mode + assert method == "session/" + persistence if os.path.exists("forget-session"): send({"id": message["id"], "error": {"code": -32002, "message": "session missing"}}) + if services_enabled: + serial += 1 + abandoned_request = serial + send({'id': serial, 'method': 'fs/write_text_file', 'params': { + 'sessionId': message['params']['sessionId'], + 'path': os.path.join(os.getcwd(), 'abandoned-write'), 'content': 'must reject'}}) continue assert message["params"]["cwd"] == os.getcwd() session = message["params"]["sessionId"] - if mode == "load": + if persistence == "load": update("old history") - send({"id": message["id"], "result": {}}) + result = {} + if services_enabled: + assert message['params']['mcpServers'][0]['args'] == ['--token', 'retained-scope-grant', + '--custom', 'opaque-generic-arg'] + result['configOptions'] = config() + send({"id": message["id"], "result": result}) update("live adjacent to restore response") + elif method == 'session/set_config_option': + assert services_enabled + with open('.opensymphony/conversation.json') as file: + assert json.load(file)['acp']['status'] != 'submitted' + if mode == 'services_pre_prompt' and not os.path.exists('pre-prompt-rejected'): + callback('fs/write_text_file', {'path': os.path.join(os.getcwd(), 'pre-prompt-write'), 'content': 'must reject'}, error=True) + callback('terminal/create', {'command': sys.executable, 'args': ['-c', "open('pre-prompt-process','w').write('must reject')"]}, error=True) + open('pre-prompt-rejected', 'w').close() + if os.path.exists('stall-config'): + with open('preparing-config', 'w') as file: + file.write('pending') + continue + config_id = message['params']['configId'] + if config_id == 'model': + model = message['params']['value'] + elif config_id == 'mode': + session_mode = message['params']['value'] + elif config_id == 'verbosity': + verbosity = message['params']['value'] + else: + raise AssertionError(config_id) + send({'id': message['id'], 'result': {'configOptions': config()}}) elif method == "session/prompt": assert message["params"]["sessionId"] == session # The marker must already exist on disk before even the first prompt byte is observed. with open(".opensymphony/conversation.json") as file: assert json.load(file)["acp"]["status"] == "submitted" text = message["params"]["prompt"][0]["text"] + if text == 'file-privacy': + content = callback('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'private-file')})['content'] + assert content == 'opaque_workspace_payload_610' + callback('fs/write_text_file', {'path': os.path.join(os.getcwd(), 'private-copy'), 'content': content}) + terminal = callback('terminal/create', {'command': sys.executable, 'args': ['-c', "import sys; sys.stdout.write(open('private-file').read())"]})['terminalId'] + assert callback('terminal/wait_for_exit', {'terminalId': terminal})['exitCode'] == 0 + assert callback('terminal/output', {'terminalId': terminal})['output'] == content + callback('terminal/release', {'terminalId': terminal}) + if text == 'late-callbacks': + import time + terminal = callback('terminal/create', {'command': sys.executable, 'args': ['-c', "import os,time; open('normal-child.pid','w').write(str(os.getpid())); time.sleep(60)"]})['terminalId'] + while not os.path.exists('normal-child.pid'): + time.sleep(0.01) + send({'id': message['id'], 'result': {'stopReason': 'end_turn'}}) + callback('fs/write_text_file', {'path': os.path.join(os.getcwd(), 'late-write'), 'content': 'idle mutation'}, error=True) + callback('terminal/create', {'command': sys.executable, 'args': ['-c', "open('late-process','w').write('idle process')"]}, error=True) + open('late-rejected', 'w').close() + continue + if text.startswith('config-'): + assert (model, session_mode, verbosity) == ('second', 'execute', 'loud') + if text != 'config-assert': + model, session_mode, verbosity = 'first', 'plan', 'quiet' + if text == 'config-remove': + open('remove-choice', 'w').close() + if text == 'config-stall': + open('stall-config', 'w').close() + send({'method': 'session/update', 'params': {'sessionId': session, 'update': { + 'sessionUpdate': 'config_option_update', 'configOptions': config()}}}) + if text == 'services-cancel': + import time + terminal = callback('terminal/create', {'command': sys.executable, 'args': ['-c', "import os,time; open('callback.pid','w').write(str(os.getpid())); time.sleep(60)"]})['terminalId'] + while not os.path.exists('callback.pid'): + time.sleep(0.01) + pending = message['id'] + update('services-active') + continue + if text == 'services-next': + callback('terminal/output', {'terminalId': terminal}, error=True) + path = os.path.join(os.getcwd(), 'second-turn-file') + callback('fs/write_text_file', {'path': path, 'content': 'fresh epoch'}) + assert callback('fs/read_text_file', {'path': path})['content'] == 'fresh epoch' + terminal = callback('terminal/create', {'command': sys.executable, 'args': ['-c', "print('fresh process')"]})['terminalId'] + assert callback('terminal/wait_for_exit', {'terminalId': terminal})['exitCode'] == 0 + callback('terminal/release', {'terminalId': terminal}) + model = 'first' + send({'method': 'session/update', 'params': {'sessionId': session, 'update': {'sessionUpdate': 'config_option_update', 'configOptions': config()}}}) if text == "crash": sys.exit(7) if text == "hang": From c79c29d9cb60e129c087225de9c72e2db7d7718f Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Tue, 22 Sep 2026 19:47:38 -0500 Subject: [PATCH 16/29] docs(memory): sync COE-610 ACP callback closeout --- docs/accessibility.md | 2 ++ docs/acp.md | 3 +++ docs/architecture.md | 2 ++ docs/build.md | 2 ++ docs/cli.md | 2 ++ docs/code-intelligence.md | 2 ++ docs/code-review.md | 2 ++ docs/codex-runtime.md | 2 ++ docs/codex.md | 2 ++ docs/configuration.md | 2 ++ docs/control.md | 2 ++ docs/data-contracts.md | 2 ++ docs/debugging.md | 2 ++ docs/desktop.md | 2 ++ docs/developer-experience.md | 2 ++ docs/docs.md | 2 ++ docs/documentation.md | 2 ++ docs/duckdb.md | 2 ++ docs/evidence.md | 2 ++ docs/experiments.md | 2 ++ docs/frontend.md | 2 ++ docs/gateway.md | 2 ++ docs/graph-view.md | 2 ++ docs/harness-runtime.md | 2 ++ docs/harness.md | 2 ++ docs/installer.md | 2 ++ docs/linear.md | 2 ++ docs/mcp.md | 2 ++ docs/memory.md | 2 ++ docs/migration.md | 2 ++ docs/model-configuration.md | 2 ++ docs/okf.md | 2 ++ docs/openhands-runtime.md | 2 ++ docs/operations.md | 2 ++ docs/orchestrator.md | 2 ++ docs/outcomes.md | 2 ++ docs/planning.md | 2 ++ docs/process-protocol.md | 2 ++ docs/provenance.md | 2 ++ docs/provider.md | 3 +++ docs/quality.md | 2 ++ docs/query-packs.md | 2 ++ docs/recovery.md | 3 +++ docs/release.md | 2 ++ docs/review.md | 3 +++ docs/run-detail.md | 2 ++ docs/runner.md | 2 ++ docs/runtime.md | 2 ++ docs/rust.md | 2 ++ docs/scheduler.md | 2 ++ docs/security.md | 2 ++ docs/sources.md | 2 ++ docs/target-repo-bootstrap.md | 2 ++ docs/task-packets.md | 2 ++ docs/template.md | 2 ++ docs/testing-and-operations.md | 2 ++ docs/testing.md | 2 ++ docs/tests.md | 2 ++ docs/threejs.md | 2 ++ docs/tui.md | 2 ++ docs/ui.md | 2 ++ docs/verifiers.md | 2 ++ docs/web.md | 2 ++ docs/workflow.md | 2 ++ docs/workspace-and-lifecycle.md | 2 ++ docs/workspace-lifecycle.md | 2 ++ docs/workspace.md | 2 ++ 67 files changed, 138 insertions(+) diff --git a/docs/accessibility.md b/docs/accessibility.md index e7bd81ca9..1aff16bc3 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/acp.md b/docs/acp.md index 50060506b..84184c401 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -13,6 +13,7 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) +- COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) ## Important invariants @@ -31,10 +32,12 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs - COE-608 - COE-609 +- COE-610 diff --git a/docs/architecture.md b/docs/architecture.md index 6602f0ef9..4b885e0b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -669,6 +669,7 @@ and updates are committed in SDK dispatch order before prompt completion. No Ope - COE-551: Scoped Cross-Repository Memory And Leaf Overlays - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -806,5 +807,6 @@ and updates are committed in SDK dispatch order before prompt completion. No Ope - COE-551 - COE-556 - COE-609 +- COE-610 diff --git a/docs/build.md b/docs/build.md index 9ec448dd3..e922a2d28 100644 --- a/docs/build.md +++ b/docs/build.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -234,5 +235,6 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/cli.md b/docs/cli.md index 6d3dfe5da..118134080 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index 2cc0289c9..40b76a872 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -285,6 +285,7 @@ references, and never returns source snippets. - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -346,5 +347,6 @@ references, and never returns source snippets. - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/code-review.md b/docs/code-review.md index f0f0361ba..bf40d9f8f 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index 9262d78a1..76cd7743f 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/codex.md b/docs/codex.md index 716ece555..5fd3a7198 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/configuration.md b/docs/configuration.md index 37f4d8f15..67138ade9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1224,6 +1224,7 @@ dependent options. - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -1383,5 +1384,6 @@ dependent options. - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/control.md b/docs/control.md index 9f11f1873..3845d93ed 100644 --- a/docs/control.md +++ b/docs/control.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index fd9dc4e0a..16408f90e 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -45,6 +45,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -60,5 +61,6 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/debugging.md b/docs/debugging.md index d19673630..2a2248580 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/desktop.md b/docs/desktop.md index c0be61cb0..5bd9e54ab 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -236,6 +237,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 - PR-196 - PR-197 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index 218f15b10..96d3fe3da 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/docs.md b/docs/docs.md index d6bdfd267..5b88946e5 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration - PR-197: Graph visualization command center and shared fixtures ## Source refs @@ -235,6 +236,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 - PR-197 diff --git a/docs/documentation.md b/docs/documentation.md index 7ca76097e..00007bbbc 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/duckdb.md b/docs/duckdb.md index 8fc3af656..c195d470a 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/evidence.md b/docs/evidence.md index 88fb8bcde..c108fc50f 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -45,6 +45,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -60,5 +61,6 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/experiments.md b/docs/experiments.md index 8fe08612d..98206d5e0 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-566 - COE-608 - COE-609 +- COE-610 diff --git a/docs/frontend.md b/docs/frontend.md index 3da88a6c6..8900c1d14 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -156,6 +157,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 - PR-196 - PR-197 diff --git a/docs/gateway.md b/docs/gateway.md index cc066bcd2..a1d729d85 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -153,6 +153,7 @@ baseline, not an issue-workspace overlay. - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration - PR-196: Desktop task and diff latency plus live-refresh UX fixes ## Source refs @@ -256,6 +257,7 @@ baseline, not an issue-workspace overlay. - COE-567 - COE-608 - COE-609 +- COE-610 - PR-196 diff --git a/docs/graph-view.md b/docs/graph-view.md index 32c152a94..300b4350d 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -154,6 +155,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 - PR-196 - PR-197 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index 79a946d68..4a68eb3da 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/harness.md b/docs/harness.md index 7ca65b250..e77bd2e9d 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-556 - COE-608 - COE-609 +- COE-610 diff --git a/docs/installer.md b/docs/installer.md index ac492891c..4c5bc3888 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/linear.md b/docs/linear.md index 51271d9ba..ce2fab1e9 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -234,5 +235,6 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/mcp.md b/docs/mcp.md index d8ea89fa2..7016e3bb8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/memory.md b/docs/memory.md index 95c3e02e9..ecafb0c06 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -845,6 +845,7 @@ are covered by the same archive operation. - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -1012,5 +1013,6 @@ are covered by the same archive operation. - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/migration.md b/docs/migration.md index 8e4b94d90..2fffe452e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index 50eb87238..d8f95f827 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/okf.md b/docs/okf.md index 79ab2664d..946bf0be6 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index 77feb8800..bdced01a3 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/operations.md b/docs/operations.md index 2df83b2a1..26f57162f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1165,6 +1165,7 @@ handoff are separate integration slices. - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -1335,5 +1336,6 @@ handoff are separate integration slices. - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index 19bcad3bb..3bde41f84 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/outcomes.md b/docs/outcomes.md index d5ae4443b..2b4d01ac3 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-564: Implement verifier execution and outcome records - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-564 - COE-608 - COE-609 +- COE-610 diff --git a/docs/planning.md b/docs/planning.md index 6475f076e..dcd80f33e 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index 2ab2b3047..f185397c3 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/provenance.md b/docs/provenance.md index 3098e92fa..b20150632 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-563: Implement task-packet admission and freeze tooling - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-563 - COE-608 - COE-609 +- COE-610 diff --git a/docs/provider.md b/docs/provider.md index b79f9b8b3..144e27fef 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -15,6 +15,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) +- COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) ## Important invariants @@ -35,6 +36,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -42,5 +44,6 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-556 - COE-608 - COE-609 +- COE-610 diff --git a/docs/quality.md b/docs/quality.md index 9a806efdf..4894032bd 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -45,6 +45,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -60,5 +61,6 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/query-packs.md b/docs/query-packs.md index b03f9ffd7..ffde8fb7d 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/recovery.md b/docs/recovery.md index a7f8397ac..879f888b1 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) +- COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) ## Important invariants @@ -33,11 +34,13 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs - COE-556 - COE-608 - COE-609 +- COE-610 diff --git a/docs/release.md b/docs/release.md index 1570d61ad..8ee73a595 100644 --- a/docs/release.md +++ b/docs/release.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -148,6 +149,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 ## 2.11 Rust toolchain boundary diff --git a/docs/review.md b/docs/review.md index 35dc59baf..777b88c90 100644 --- a/docs/review.md +++ b/docs/review.md @@ -15,6 +15,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-556 contributed: PR #239: COE-556: Add bottom-up subtree cleanup and recovery (merge `bc471f8`) - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) +- COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) ## Important invariants @@ -35,6 +36,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -42,5 +44,6 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-556 - COE-608 - COE-609 +- COE-610 diff --git a/docs/run-detail.md b/docs/run-detail.md index a7b15dc5e..a9f6ee8f6 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/runner.md b/docs/runner.md index d3998edc1..007864e03 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/runtime.md b/docs/runtime.md index cf3be5108..0e9891b24 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/rust.md b/docs/rust.md index a7e766807..1b105b87d 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/scheduler.md b/docs/scheduler.md index ed80ac33d..355a9e8a1 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-566: Implement configurable run matrices and scheduling - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-566 - COE-608 - COE-609 +- COE-610 diff --git a/docs/security.md b/docs/security.md index e30d25447..f923e4bec 100644 --- a/docs/security.md +++ b/docs/security.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -234,5 +235,6 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/sources.md b/docs/sources.md index d97d46c32..511dfa416 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -367,6 +367,7 @@ write/delete sharing until file I/O or terminal spawn finishes. - COE-563: Implement task-packet admission and freeze tooling - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -450,5 +451,6 @@ write/delete sharing until file I/O or terminal spawn finishes. - COE-563 - COE-608 - COE-609 +- COE-610 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index 6ffe465f6..18530678a 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/task-packets.md b/docs/task-packets.md index 2bd7848d5..912cb486c 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-565: Implement isolated workspace materialization - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-565 - COE-608 - COE-609 +- COE-610 diff --git a/docs/template.md b/docs/template.md index 748ceedc3..72cb4c2b2 100644 --- a/docs/template.md +++ b/docs/template.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 152598389..786ba47d4 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -893,6 +893,7 @@ targets remain required for changes to persistence or the host observation seam. - COE-555: Parent Repair Review And Merge Lifecycle - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -1046,5 +1047,6 @@ targets remain required for changes to persistence or the host observation seam. - COE-555 - COE-556 - COE-609 +- COE-610 diff --git a/docs/testing.md b/docs/testing.md index 4d4fa2f66..e998347be 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -232,6 +233,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 - PR-196 - PR-197 diff --git a/docs/tests.md b/docs/tests.md index 3d97a242d..746da6a16 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/threejs.md b/docs/threejs.md index df72ca232..cfb053106 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/tui.md b/docs/tui.md index b32d99632..b19619303 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -224,5 +225,6 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/ui.md b/docs/ui.md index 0ba2941a4..3c5675b30 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -127,6 +127,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -226,6 +227,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 - PR-196 - PR-197 diff --git a/docs/verifiers.md b/docs/verifiers.md index b4ccc89ed..13da3abec 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-564: Implement verifier execution and outcome records - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-564 - COE-608 - COE-609 +- COE-610 diff --git a/docs/web.md b/docs/web.md index 6f4691ff0..752200e7b 100644 --- a/docs/web.md +++ b/docs/web.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/workflow.md b/docs/workflow.md index 978c710c6..fda5b660c 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index ba51a562d..6a538b1b2 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -1055,6 +1055,7 @@ or MCP grants. - COE-419: Hosted Auth Placeholders And Web Parity - COE-473: Desktop task graph dependency and run detail parity - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -1076,5 +1077,6 @@ or MCP grants. - COE-419 - COE-473 - COE-609 +- COE-610 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index a0c84cf20..d40174508 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -88,6 +88,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-567: Implement run lifecycle and process-protocol primitives - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -146,5 +147,6 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-567 - COE-608 - COE-609 +- COE-610 diff --git a/docs/workspace.md b/docs/workspace.md index 1494ee704..af5bce78d 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-565: Implement isolated workspace materialization - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery +- COE-610: ACP Client Callbacks And Session Configuration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-565 - COE-608 - COE-609 +- COE-610 From 80e5ac3729e4c4d0f325f9f5f035ea4767d828f3 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Wed, 23 Sep 2026 07:37:00 -0500 Subject: [PATCH 17/29] COE-611: Route production workers through ACP profiles (#245) * feat(acp): route production workers through retained sessions Persist ACP profile routes and reuse verified workspace preparation, hooks, instructions, scoped grants and runtime envelopes in the actual run path. Integrate lifecycle reports, cancellation, recovery and fenced retirement; keep ACP-only execution independent of OpenHands startup. Expose profile and run capabilities through Rust and TypeScript schemas, and project redacted updates without charging replay or invented usage. Cover actual CLI execution, profile switches, retained recovery, ten concurrent workers, corrupt evidence and native harness regressions. Validation: 24 focused ACP tests and all-target clippy pass. The earlier complete routing suite passed 2,126 tests; later lifecycle refinements passed focused regressions. TypeScript: 726 tests and full type-check pass. Final model/config/MCP wiring follows the reviewed COE-610 integration. * fix(acp): preserve structural activity and reported usage Give structural updates summaries so scheduler activity retains their payloads. Preserve only recognized numeric prompt-response usage counters through frame redaction while keeping credential redaction and known secret matching. Keep response observations separate from context usage and avoid inferring delta accumulation into native aggregate counters. Clear the native server URL from ACP metadata. Exercise public plan and usage projection through the actual CLI, waiting for both durable success and after_run evidence before asserting completed hook execution. Validation: actual CLI regression passes, 25 focused ACP tests pass, and all-target clippy is clean. Final combined validation follows COE-610. * fix(acp): resolve profile environment for readiness checks Resolve executable preflight against profile-mapped PATH values and source-only environment removal, matching launch precedence. Report missing references before executable availability, reject platform-name collisions, and recognize Windows bare executable names. Validation: 17 focused ACP tests passed, including the mapped PATH and missing reference regression; all-target system-DuckDB clippy passed. * feat(acp): validate model routes and recover run capabilities Allow direct ACP model IDs through workflow and environment precedence, while rejecting unmapped OpenHands model profiles. Strengthen the central configuration authority test against repository-local profile overrides. Production application to session configuration follows the COE-610 merge. Recover public run capabilities from durable negotiation when reconciling a completed prompt, preserving the persisted profile without peer replay. Validation: workflow model selection, central authority, retained profile recovery tests and all-target system-DuckDB clippy passed; fmt and diff checks are clean. * fix(acp): consume failed worker joins once during abort Remove the consumed task handle and stale ACP registry entry when worker finalization returns a JoinError. Report failure and the already-observed harness stop proof through WorkerUpdate instead of allowing later polling to panic on the completed JoinHandle. Validation: forced-task-abort regression reproduced the Tokio panic before this fix; all 10 focused ACP routing and lifecycle tests now pass. Format, diff checks and all-target system-DuckDB clippy are clean. * fix(acp): fence unexpected worker task failures during polling Remove stale ACP active-session entries when polling observes a failed worker task. Cancel retained ownership work and use the owner snapshot to report a stopped failure or a detached uncertain outcome through worker messages, preserving scheduler cleanup and retry authority. Validation: all 11 focused ACP routing/config/lifecycle tests passed, including ordinary polling after forced task termination. All-target system-DuckDB clippy, format and diff checks passed. * fix(acp): box worker future on shared task stack Summary: - Heap-allocate the ACP run future before awaiting it in the worker task. Rationale: - The inline ACP future enlarged every route's task frame and caused the existing OpenHands gateway-cancel CLI test to overflow a Tokio stack. Tests: - cargo test-system-duckdb --test run (9 passed) - cargo test-system-duckdb --all-targets (2133 passed, 4 ignored) - cargo clippy-system-duckdb - cargo fmt --all -- --check * fix(acp): verify durable stop after pre-prompt owner exit Summary: - Confirm cancellation and abort outcomes from the exact owner, generation, run, and workspace when the live ACP control channel closes. - Keep uncertain submissions fenced and make abort coverage wait for the intended submitted prompt. - Read the first CLI prompt from history to avoid a scheduler retry race. Rationale: - COE-610 preparation can cancel before transport submission and close the owner after a safe stopped checkpoint; the scheduler must recognize that checkpoint without trusting an unrelated or uncertain record. Tests: - cargo test-system-duckdb --all-targets (2174 passed, 4 ignored) - cargo test-system-duckdb opensymphony_cli::orchestrator_run::backends::tests::acp_ - cargo test-system-duckdb --test run - cargo clippy-system-duckdb; cargo fmt --check; git diff --check - npm run type-check; npm test (759 passed) * fix(acp): fence scheduled interrupts and recovered turns Summary: - Resolve scheduler interrupts by issue identity and verify the ACP owner. - Match recovered finished turns to their prepared run and attempt. - Include resolved worker credentials in profile preflight and publish negotiated model-selection capability. - Wait for stop evidence on retained pre-submission cancellation. Rationale: - Generated worker IDs differ from scheduler interrupt identifiers, and retained ACP state can describe a prior turn during a new preparation. - Capability and preflight projections must match the launched session. Tests: - cargo test-system-duckdb --all-targets (2,177 passed; 4 ignored) - cargo clippy-system-duckdb; cargo fmt --check - npm run type-check; npm test (759 passed on prior published head) * test(cli): wait for Linux gateway readiness with diagnostics Summary: - Give the gateway-cancel CLI fixture the same bounded readiness window used by its neighboring recovery fixture. - Drain child stderr and report process status plus the last gateway issue state when readiness still fails. Rationale: - GitHub Linux CI hit the 10-second snapshot limit on three runs while the same fixture and all sibling CLI tests passed locally. - A further failure must expose whether startup exited or remained in a non-running state rather than another opaque timeout. Tests: - cargo test-system-duckdb --test run (9 passed) - cargo test-system-duckdb --all-targets (2,177 passed; 4 ignored) - cargo clippy-system-duckdb; cargo fmt --check; git diff --check * fix(openhands): bound worker stack during attach and event polling Summary: - Isolate OpenHands attach in a cancellation-owned task and box the shared routing, turn, and event-poll futures. - Constrain the gateway-cancel CLI fixture below Linux's default worker stack, retain child stderr diagnostics, and restore 10-second readiness. - Document attach cancellation ownership. Rationale: - Linux CI showed the run child exiting 134 from a Tokio worker stack overflow before the gateway bound; its readiness timeout was secondary. - The constrained-stack fixture reproduces that route while preserving normal cancellation and WebSocket attachment behavior. Tests: - cargo test-system-duckdb --all-targets (2,177 passed; 4 ignored) - cargo test-system-duckdb --test run (9 passed) - cargo clippy-system-duckdb - cargo fmt --all -- --check * fix(acp): preserve recovery identity and parent command evidence Summary: - Scope recovered outcomes and memory grants to the exact run and attempt. - Retire retained ACP owners when effective profile or credentials change. - Project terminal callback commands into parent verification receipts. - Align preflight exclusions and environment decoding with worker launch. Rationale: - Prior-turn checkpoints and stale owners must not govern new attempts. - Parent final evidence requires observed command start and exit results. - Ambient invalid Unicode must not crash an otherwise valid launch. Tests: - cargo test-system-duckdb --all-targets: 2182 passed, 4 ignored - Focused ACP callback fixture and recovery regressions passed - cargo clippy-system-duckdb; cargo fmt --all -- --check - git diff --check * fix(acp): bind recovery route to the prepared run Persist the selected ACP profile and model with each run so a restart before owner launch cannot inherit the previous turn's workspace route. Clear prior terminal evidence when reserving a different run, and resolve credential source aliases with platform environment-name rules. Cover stale legacy routes, changed-model recovery, claim checkpoints, and credential casing. Validate with the full 2,186-test all-target Rust suite. * fix(acp): preserve effective model and memory run scope Bind the profile-selected model before preparing ACP runs, acknowledge fresh memory grants only after a new owner reports launch, and rotate retained owners when per-run memory environment values change. Derive memory prompt guidance from the managed worker overlay instead of inherited shell values. Cover changed-profile recovery after owner retirement, grant revocation and failed setup, run-scope rotation, and stale ambient scope. Validate with the full 2,191-test all-target Rust suite. * fix: preserve ACP prompt seeding and long-running turns * fix(acp): preserve prompt and parent scope across retained runs Summary: - Persist workflow prompt seeding separately from transient ACP status, including legacy manifest migration and fresh-session reset. - Keep current run envelopes while binding retained ACP conversations. - Render repository-neutral parent memory guidance from scoped worker env. - Cover restored seeded sessions, parent checkout drift, and cancellations. Rationale: - A restored Ready session may already have received the full workflow; replaying it can repeat completed actions. - A retained owner holds the prior envelope, which must not replace the current hierarchy and checkout snapshot. - Parent memory grants intentionally omit an execution repository. Tests: - cargo test-system-duckdb --all-targets (2,199 passed, 4 ignored) - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --cached --check * fix(acp): scope memory grants and recover lagged activity Summary: - Strip ambient and workflow memory variables before ACP launch, then inject only the managed run grant; reject reserved env mappings. - Recover a lagged subscriber from the contiguous post-cursor source tail and project unknown session updates as bounded redacted activity. - Cover child and terminal environments, profile mapping, source gaps, and unknown updates; document the production behavior. Rationale: - Inherited admin credentials could reach the ACP peer or callbacks, historical evictions could falsely fence a current run, and future updates could disappear from scheduler stall accounting. Tests: - cargo test-system-duckdb --all-targets (2205 passed, 4 ignored) - cargo clippy-system-duckdb - cargo fmt --all --check - git diff --check * fix(acp): retain terminal evidence and callback liveness Summary: - Checkpoint bounded future ACP stop reasons as finished peer evidence and preserve their optional usage while keeping outcomes non-retryable. - Include run-bound pending OpenHands manifests in startup recovery when a configured harness has changed. - Project filesystem callback requests and responses as payload-free scheduler activity with bounded response correlation. Rationale: - Conclusive future peer responses must not become uncertain delivery, pending native ownership must initialize its transport after restart, and ongoing callback traffic must prevent false scheduler stalls. Tests: - cargo test-system-duckdb --all-targets (2209 passed, 4 ignored) - cargo test-system-duckdb --lib filesystem_callbacks_project_payload_free_scheduler_activity - cargo clippy-system-duckdb - cargo fmt --all --check - git diff --check * fix(acp): preserve parent sessions and redact terminal reasons * fix(acp): keep terminal output polls visible to stall detection --- AGENTS.md | 11 +- crates/opensymphony-acp/src/durable.rs | 356 +- crates/opensymphony-acp/src/host.rs | 181 +- crates/opensymphony-acp/src/lib.rs | 147 +- crates/opensymphony-acp/src/projection.rs | 1147 ++++++ crates/opensymphony-acp/src/services.rs | 15 +- crates/opensymphony-acp/src/session_config.rs | 8 + crates/opensymphony-cli/src/lib.rs | 3 + crates/opensymphony-cli/src/memory.rs | 10 + .../src/orchestrator_run/backends.rs | 3425 +++++++++++++++-- .../src/orchestrator_run/backends/acp.rs | 941 +++++ .../src/orchestrator_run/config.rs | 24 +- .../src/orchestrator_run/mod.rs | 55 +- .../src/orchestrator_run/snapshot.rs | 49 +- crates/opensymphony-cli/tests/run.rs | 236 +- crates/opensymphony-cli/tests/tui.rs | 1 + crates/opensymphony-control/src/lib.rs | 1 + .../tests/control_plane.rs | 1 + .../opensymphony-domain/src/control_plane.rs | 3 + crates/opensymphony-domain/src/lib.rs | 1 + crates/opensymphony-domain/src/runtime.rs | 4 + .../tests/snapshot_serialization.rs | 1 + .../src/capability.rs | 69 +- crates/opensymphony-gateway-schema/src/run.rs | 3 + .../tests/gateway_schema.rs | 35 +- crates/opensymphony-gateway/src/lib.rs | 32 +- crates/opensymphony-gateway/tests/gateway.rs | 16 + crates/opensymphony-openhands/src/client.rs | 17 +- crates/opensymphony-openhands/src/session.rs | 120 +- crates/opensymphony-orchestrator/src/lib.rs | 1 + .../src/scheduler.rs | 118 +- .../tests/scheduler.rs | 20 + crates/opensymphony-tui/src/lib.rs | 1 + crates/opensymphony-tui/tests/reducer.rs | 2 + crates/opensymphony-workflow/src/acp.rs | 8 +- crates/opensymphony-workflow/src/resolve.rs | 4 +- crates/opensymphony-workspace/src/lib.rs | 23 +- crates/opensymphony-workspace/src/manager.rs | 1 + crates/opensymphony-workspace/src/models.rs | 43 + docs/architecture.md | 19 +- docs/configuration.md | 37 +- docs/harness-adapter-compatibility.md | 64 +- docs/openhands-agent-server.md | 8 + docs/operations.md | 75 +- docs/testing-and-operations.md | 13 + docs/workspace-and-lifecycle.md | 56 +- .../__tests__/capability.test.ts | 16 + .../__tests__/fixtures/acp-capability.json | 23 + packages/gateway-schema/src/capability.ts | 70 + packages/gateway-schema/src/index.ts | 2 +- packages/gateway-schema/src/run.ts | 2 + tests/acp.rs | 119 +- tests/acp_session_host.rs | 113 + tests/action_handler.rs | 1 + tests/fixtures/acp_session_peer.py | 3 + tests/fixtures/acp_worker_peer.py | 120 + tests/task_graph_mutations_routes.rs | 1 + 57 files changed, 7414 insertions(+), 461 deletions(-) create mode 100644 crates/opensymphony-acp/src/projection.rs create mode 100644 crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs create mode 100644 packages/gateway-schema/__tests__/capability.test.ts create mode 100644 packages/gateway-schema/__tests__/fixtures/acp-capability.json create mode 100644 tests/fixtures/acp_worker_peer.py diff --git a/AGENTS.md b/AGENTS.md index 07b88b64c..df03df97c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ Preferred crate and trait boundaries: - `opensymphony-linear` - `opensymphony-openhands` - `opensymphony-codex` +- `opensymphony-acp` - `opensymphony-orchestrator` - `opensymphony-control` - `opensymphony-cli` @@ -156,11 +157,19 @@ The runtime client must: ### Keep harness capability discovery public - Public harness metadata belongs in `opensymphony-gateway-schema::capability::HarnessCapability` and the `/api/v1/capabilities` response. -- Use stable harness kind strings such as `openhands_agent_server`, `codex_app_server`, and `rust_native`; do not expose private adapter type names to clients. +- Use stable harness kind strings such as `openhands_agent_server`, `codex_app_server`, `acp`, and `rust_native`; do not expose private adapter type names to clients. - Concrete harness adapters should implement the domain `HarnessAdapter` capability boundary and keep OpenHands, Codex, or future in-process protocol details inside their adapter modules. - Future or experimental harnesses may be advertised as unavailable capability entries, but their feature gaps must be explicit. - When changing harness capability discovery, update gateway schema round-trip tests, the gateway capabilities endpoint test, adapter-boundary tests, and `docs/harness-adapter-compatibility.md`. +The ACP harness uses named profiles through the production `opensymphony run` +worker backend and the retained `opensymphony_acp::SessionHost`. Keep protocol +ownership inside that module and scheduling decisions in the orchestrator. +Persist the selected profile and route for recovery; never replay an uncertain +submission. ACP-only execution must not start an OpenHands server. Public ACP +capabilities distinguish adapter support, profile preflight and negotiated run +support; see `docs/harness-adapter-compatibility.md`. + The local Codex app-server harness uses the `opensymphony_codex` internal module boundary and is advertised as an available local stdio capability. Keep Codex app-server launch, JSON-RPC lifecycle, event-normalization, generated-contract, diff --git a/crates/opensymphony-acp/src/durable.rs b/crates/opensymphony-acp/src/durable.rs index fc5c843f4..85bd89db1 100644 --- a/crates/opensymphony-acp/src/durable.rs +++ b/crates/opensymphony-acp/src/durable.rs @@ -77,12 +77,62 @@ impl Drop for Durability { } impl Durability { + pub(super) fn process_is_absent(&self) -> bool { + #[cfg(unix)] + { + verify_prior_process(self.state().process).is_ok() + } + #[cfg(not(unix))] + { + // Windows owner-death recovery relies on closing the JobObject; + // that assumption does not prove a live owner's failed stop. + self.state().process == AcpProcessState::Stopped + } + } + /// Zero generation is the trusted host's request to claim the latest record. /// A nonzero generation requires an exact match before reserving its successor. pub(super) async fn open( manager: WorkspaceManager, workspace: WorkspaceHandle, identity: AcpSessionIdentity, + ) -> Result { + Self::claim(manager, workspace, identity, true, None).await + } + + pub(super) async fn open_bound_parent( + manager: WorkspaceManager, + workspace: WorkspaceHandle, + identity: AcpSessionIdentity, + expected_session_id: String, + ) -> Result { + Self::claim( + manager, + workspace, + identity, + true, + Some(expected_session_id), + ) + .await + } + + pub(super) async fn retire_persisted( + manager: WorkspaceManager, + workspace: WorkspaceHandle, + identity: AcpSessionIdentity, + ) -> Result<(), DurabilityError> { + // The claim verifies both the exclusive owner lock and process absence; + // reserve no launch, so a cleanup crash cannot leave LaunchPending behind. + let _guard = Self::claim(manager, workspace, identity, false, None).await?; + Ok(()) + } + + async fn claim( + manager: WorkspaceManager, + workspace: WorkspaceHandle, + identity: AcpSessionIdentity, + reserve_launch: bool, + expected_session_id: Option, ) -> Result { validate_identity(&identity)?; let issue = manager @@ -149,8 +199,10 @@ impl Durability { }, session_id: None, initialization: Value::Null, + model_selection: false, status: AcpSessionStatus::Ready, stop_reason: None, + workflow_prompt_seeded: Some(false), recovery: AcpRecovery::Fresh, owner_id: String::new(), process: AcpProcessState::Stopped, @@ -163,10 +215,33 @@ impl Durability { { return Err(DurabilityError::Binding("issue")); } - if let Some(run) = manager.load_run_manifest(&workspace).await? { + let run = if reserve_launch { + manager.load_run_manifest(&workspace).await? + } else { + None + }; + if expected_session_id.is_some() && run.is_none() { + return Err(DurabilityError::Binding("parent run")); + } + if let Some(run) = run { if run.run_id != identity.run_id || run.attempt != identity.attempt { return Err(DurabilityError::Binding("run identity")); } + if let Some(expected) = expected_session_id.as_deref() + && run + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.as_deref()) + != Some(expected) + { + return Err(DurabilityError::Binding("parent conversation")); + } + if let Some(envelope) = run.parent_runtime_envelope { + if envelope.harness != "acp" || envelope.workspace_path != identity.workspace_path { + return Err(DurabilityError::Binding("parent runtime envelope")); + } + manifest.parent_runtime_envelope = Some(envelope); + } if let Some(envelope) = run.runtime_envelope { if envelope.harness != "acp" || envelope.checkout_path != identity.workspace_path @@ -183,7 +258,21 @@ impl Durability { .acp .as_mut() .ok_or(DurabilityError::NativeManifest)?; - validate_binding(&state.identity, &identity)?; + if let Some(expected) = expected_session_id.as_deref() { + if state.session_id.as_deref() != Some(expected) + || manifest.conversation_id.as_str() != expected + || manifest + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.as_deref()) + != Some(expected) + { + return Err(DurabilityError::Binding("parent conversation")); + } + validate_static_binding(&state.identity, &identity)?; + } else { + validate_binding(&state.identity, &identity)?; + } if state.harness != "acp" || manifest.conversation_id != state.session_id.as_deref().unwrap_or_default() { @@ -196,18 +285,39 @@ impl Durability { return Err(DurabilityError::Uncertain); } verify_prior_process(state.process)?; + // Migrate before a new run clears the old terminal status and stop reason. + if state.workflow_prompt_seeded.is_none() { + state.workflow_prompt_seeded = Some(state.workflow_prompt_seeded()); + } if identity.generation != 0 && identity.generation != state.identity.generation { return Err(DurabilityError::Binding("connection generation")); } - state.identity.generation = state - .identity - .generation - .checked_add(1) - .ok_or(DurabilityError::Binding("connection generation exhausted"))?; - state.identity.run_id = identity.run_id; - state.identity.attempt = identity.attempt; - state.owner_id = uuid::Uuid::new_v4().to_string(); - state.process = AcpProcessState::LaunchPending; + if reserve_launch { + if expected_session_id.is_some() { + // The old owner has stopped and the parent conversation is + // bound to this run. Rotate only the process/grant revision; + // preserve the session ID for negotiated load/resume. + state.identity.profile_fingerprint = identity.profile_fingerprint.clone(); + state.identity.credential_scope = identity.credential_scope.clone(); + } + let new_run = state.identity.run_id != identity.run_id + || state.identity.attempt != identity.attempt; + state.identity.generation = state + .identity + .generation + .checked_add(1) + .ok_or(DurabilityError::Binding("connection generation exhausted"))?; + state.identity.run_id = identity.run_id; + state.identity.attempt = identity.attempt; + if new_run { + state.status = AcpSessionStatus::Ready; + state.stop_reason = None; + } + state.owner_id = uuid::Uuid::new_v4().to_string(); + state.process = AcpProcessState::LaunchPending; + } else { + state.process = AcpProcessState::Stopped; + } let durable = Self { manager, workspace, @@ -255,6 +365,11 @@ impl Durability { envelope.run_id.clone_from(&self.state().identity.run_id); envelope.attempt = self.state().identity.attempt; } + if let Some(envelope) = &mut manifest.parent_runtime_envelope { + envelope.conversation_binding = self.state().session_id.clone(); + envelope.run_id.clone_from(&self.state().identity.run_id); + envelope.attempt = self.state().identity.attempt; + } self.manager .write_json_artifact_atomically(&self.workspace, &path, &manifest) .await?; @@ -288,6 +403,7 @@ impl Durability { &mut self, session_id: String, initialization: Value, + model_selection: bool, recovery: AcpRecovery, reset_reason: Option, ) -> Result<(), DurabilityError> { @@ -302,12 +418,28 @@ impl Durability { let state = self.state_mut(); state.session_id = Some(session_id); state.initialization = initialization; + state.model_selection = model_selection; state.recovery = recovery; + if recovery == AcpRecovery::Fresh { + state.workflow_prompt_seeded = Some(false); + } state.status = AcpSessionStatus::Ready; state.stop_reason = None; self.persist().await } + pub(super) async fn set_model_selection( + &mut self, + model_selection: bool, + ) -> Result<(), DurabilityError> { + self.require_quiescent()?; + if self.state().model_selection != model_selection { + self.state_mut().model_selection = model_selection; + self.persist().await?; + } + Ok(()) + } + pub(super) async fn submitted( &mut self, run_id: String, @@ -332,6 +464,9 @@ impl Durability { self.manifest.fresh_conversation = false; let state = self.state_mut(); state.status = AcpSessionStatus::Finished; + if stop_reason != "cancelled_before_prompt" { + state.workflow_prompt_seeded = Some(true); + } state.stop_reason = Some(stop_reason); self.persist().await } @@ -407,8 +542,8 @@ fn validate_binding( stored: &AcpSessionIdentity, requested: &AcpSessionIdentity, ) -> Result<(), DurabilityError> { + validate_static_binding(stored, requested)?; for (matches, name) in [ - (stored.profile_id == requested.profile_id, "profile"), ( stored.profile_fingerprint == requested.profile_fingerprint, "profile fingerprint", @@ -417,6 +552,20 @@ fn validate_binding( stored.credential_scope == requested.credential_scope, "credential scope", ), + ] { + if !matches { + return Err(DurabilityError::Binding(name)); + } + } + Ok(()) +} + +fn validate_static_binding( + stored: &AcpSessionIdentity, + requested: &AcpSessionIdentity, +) -> Result<(), DurabilityError> { + for (matches, name) in [ + (stored.profile_id == requested.profile_id, "profile"), ( stored.workspace_path == requested.workspace_path, "workspace", @@ -538,10 +687,152 @@ mod tests { drop(second); let mut stale = input; stale.generation = 1; - assert!(matches!( - Durability::open(manager(root.path()), workspace, stale).await, - Err(DurabilityError::Binding("connection generation")) - )); + let stale_result = Durability::open(manager(root.path()), workspace, stale) + .await + .map(|_| ()); + assert!( + matches!( + stale_result, + Err(DurabilityError::Binding("connection generation")) + ), + "{stale_result:?}" + ); + } + + #[tokio::test] + async fn new_claim_clears_prior_terminal_outcome_before_launch() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let first_identity = identity(&workspace); + let mut first = Durability::open( + manager(root.path()), + workspace.clone(), + first_identity.clone(), + ) + .await + .expect("first owner"); + first + .ready( + "session-1".into(), + Value::Null, + false, + AcpRecovery::Fresh, + None, + ) + .await + .expect("ready first turn"); + first + .submitted(first_identity.run_id, first_identity.attempt) + .await + .expect("submit first turn"); + first + .finished("end_turn".into()) + .await + .expect("first terminal outcome"); + first.stopped().await.expect("quiescent"); + drop(first); + + let mut next_identity = identity(&workspace); + next_identity.run_id = "run-2".into(); + next_identity.attempt = 2; + let next = Durability::open(manager(root.path()), workspace.clone(), next_identity) + .await + .expect("next owner"); + assert_eq!(next.state().process, AcpProcessState::LaunchPending); + assert_eq!(next.state().status, AcpSessionStatus::Ready); + assert_eq!(next.state().stop_reason, None); + assert!(next.state().workflow_prompt_seeded()); + let persisted = manager(root.path()) + .load_conversation_manifest(&workspace) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state"); + assert_eq!(persisted.identity.run_id, "run-2"); + assert_eq!(persisted.identity.attempt, 2); + assert_eq!(persisted.status, AcpSessionStatus::Ready); + assert_eq!(persisted.stop_reason, None); + assert_eq!(persisted.workflow_prompt_seeded, Some(true)); + } + + #[tokio::test] + async fn legacy_finished_prompt_is_migrated_before_new_run_claim() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let input = identity(&workspace); + let mut first = Durability::open(manager(root.path()), workspace.clone(), input.clone()) + .await + .expect("first owner"); + first + .ready( + "session-1".into(), + Value::Null, + false, + AcpRecovery::Fresh, + None, + ) + .await + .expect("ready"); + first + .submitted(input.run_id, input.attempt) + .await + .expect("submitted"); + first.finished("end_turn".into()).await.expect("finished"); + first.stopped().await.expect("stopped"); + drop(first); + let mut legacy = manager(root.path()) + .load_conversation_manifest(&workspace) + .await + .expect("manifest") + .expect("conversation"); + legacy.acp.as_mut().expect("ACP").workflow_prompt_seeded = None; + manager(root.path()) + .write_json_artifact_atomically( + &workspace, + &workspace.conversation_manifest_path(), + &legacy, + ) + .await + .expect("legacy manifest"); + + let mut next = identity(&workspace); + next.run_id = "run-2".into(); + next.attempt = 2; + let claimed = Durability::open(manager(root.path()), workspace, next) + .await + .expect("second owner"); + assert_eq!(claimed.state().status, AcpSessionStatus::Ready); + assert_eq!(claimed.state().workflow_prompt_seeded, Some(true)); + } + + #[tokio::test] + async fn pre_prompt_cancellation_does_not_seed_workflow_context() { + let root = tempfile::tempdir().expect("temp"); + let workspace = workspace(root.path()).await; + let input = identity(&workspace); + let mut owner = Durability::open(manager(root.path()), workspace, input.clone()) + .await + .expect("owner"); + owner + .ready( + "session-1".into(), + Value::Null, + false, + AcpRecovery::Fresh, + None, + ) + .await + .expect("ready"); + owner + .submitted(input.run_id, input.attempt) + .await + .expect("submitted"); + owner + .finished("cancelled_before_prompt".into()) + .await + .expect("cancelled"); + assert!(!owner.state().workflow_prompt_seeded()); } #[test] @@ -570,10 +861,13 @@ mod tests { .expect("owner"); assert_eq!(owner.state().process, AcpProcessState::LaunchPending); drop(owner); - assert!(matches!( - Durability::open(manager(root.path()), workspace, input).await, - Err(DurabilityError::ProcessUncertain) - )); + let result = Durability::open(manager(root.path()), workspace, input) + .await + .map(|_| ()); + assert!( + matches!(result, Err(DurabilityError::ProcessUncertain)), + "{result:?}" + ); } #[cfg(unix)] @@ -618,6 +912,7 @@ mod tests { .ready( "opaque/session:1".into(), json!({"protocolVersion": 1}), + false, AcpRecovery::Fresh, None, ) @@ -642,6 +937,7 @@ mod tests { .ready( "replacement".into(), Value::Null, + false, AcpRecovery::Fresh, Some("reset".into()) ) @@ -681,6 +977,7 @@ mod tests { .ready( "opaque/session:1".into(), json!({"protocolVersion": 1}), + false, AcpRecovery::RestoredLoad, None, ) @@ -696,9 +993,13 @@ mod tests { .expect("terminal evidence"); restored.stopped().await.expect("supervised process reaped"); drop(restored); - let mut reopened = Durability::open(manager(root.path()), workspace.clone(), input.clone()) - .await - .expect("finished recovery"); + let mut finished_identity = input.clone(); + finished_identity.run_id = "run-3".into(); + finished_identity.attempt = 3; + let mut reopened = + Durability::open(manager(root.path()), workspace.clone(), finished_identity) + .await + .expect("finished recovery"); assert_eq!(reopened.state().status, AcpSessionStatus::Finished); assert_eq!(reopened.state().stop_reason.as_deref(), Some("end_turn")); reopened.uncertain().await.expect("explicit risk fence"); @@ -815,7 +1116,13 @@ mod tests { let before = std::fs::read(workspace.conversation_manifest_path()).expect("reservation"); assert!(matches!( owner - .ready("bad\nsession".into(), Value::Null, AcpRecovery::Fresh, None) + .ready( + "bad\nsession".into(), + Value::Null, + false, + AcpRecovery::Fresh, + None + ) .await, Err(DurabilityError::InvalidMetadata) )); @@ -824,6 +1131,7 @@ mod tests { .ready( "session".into(), json!("x".repeat(MAX_MANIFEST_BYTES)), + false, AcpRecovery::Fresh, None ) diff --git a/crates/opensymphony-acp/src/host.rs b/crates/opensymphony-acp/src/host.rs index 4e66cc65f..9408bca89 100644 --- a/crates/opensymphony-acp/src/host.rs +++ b/crates/opensymphony-acp/src/host.rs @@ -43,6 +43,23 @@ impl Default for RetentionPolicy { } } +/// Retire a durable, known-quiescent session after its original host is gone. +/// The owner lock and prior process check must both succeed; this never launches +/// a peer or resolves an ambiguous prompt by assuming its process exited. +pub async fn retire_persisted_session( + manager: WorkspaceManager, + workspace: WorkspaceHandle, +) -> Result<(), HostError> { + let state = manager + .load_conversation_manifest(&workspace) + .await + .map_err(|_| HostError::Persistence)? + .and_then(|manifest| manifest.acp) + .ok_or(HostError::NativeManifest)?; + Durability::retire_persisted(manager, workspace, state.identity).await?; + Ok(()) +} + /// Host callers supply the scheduler's verified workspace and current credential/grant revision. pub struct SessionLaunch { pub manager: WorkspaceManager, @@ -52,6 +69,9 @@ pub struct SessionLaunch { pub context: LaunchContext, pub limits: ClientLimits, pub require_persistence: bool, + /// A parent continuation may refresh its process credentials only while + /// retaining this already-authoritative ACP session binding. + pub expected_session_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)] @@ -217,6 +237,47 @@ impl EventPublisher { pub struct SourceHistory { pub events: Vec, pub truncated: bool, + /// Latest published source cursor, including a frame too large to retain. + #[serde(default)] + pub latest_cursor: Option<(u64, u64)>, +} +impl SourceHistory { + /// A sticky historical eviction is harmless when every frame after this + /// run's processed cursor still appears in the retained contiguous tail. + pub fn covers_since(&self, cursor: (u64, u64)) -> bool { + let Some(latest) = self.latest_cursor else { + return true; + }; + if latest.0 != cursor.0 { + return false; + } + if latest.1 <= cursor.1 { + return true; + } + let Some(mut expected) = cursor.1.checked_add(1) else { + return false; + }; + for event in &self.events { + if let SessionEvent::Source { + generation, frame, .. + } = event + && *generation == cursor.0 + && frame.sequence >= expected + { + if frame.sequence != expected { + return false; + } + if expected == latest.1 { + return true; + } + let Some(next) = expected.checked_add(1) else { + return false; + }; + expected = next; + } + } + false + } } struct EventHistory { events: VecDeque<(SessionEvent, usize)>, @@ -224,9 +285,16 @@ struct EventHistory { max_bytes: usize, max_frames: usize, truncated: bool, + latest_cursor: Option<(u64, u64)>, } impl EventHistory { fn retain(&mut self, event: SessionEvent) -> bool { + if let SessionEvent::Source { + generation, frame, .. + } = &event + { + self.latest_cursor = Some((*generation, frame.sequence)); + } let bytes = serde_json::to_vec(&event).map_or(usize::MAX, |v| v.len()); if bytes > self.max_bytes { self.truncated = true; @@ -277,6 +345,7 @@ impl SessionHandle { .map(|(event, _)| event.clone()) .collect(), truncated: history.truncated, + latest_cursor: history.latest_cursor, } } pub async fn inspect(&self) -> Result { @@ -427,6 +496,10 @@ impl SessionHost { let _ = reply.send(result); } HostCommand::Open { mut launch, reply } => { + if launch.expected_session_id.is_some() && !launch.require_persistence { + let _ = reply.send(Err(HostError::PersistenceUnsupported)); + continue; + } let key = launch.workspace.issue_id().to_owned(); let fingerprint = profile_fingerprint( &launch.profile, @@ -438,10 +511,19 @@ impl SessionHost { continue; }; launch.identity.profile_fingerprint = fingerprint; - if super::validate_launch(&launch.profile, &launch.context, &launch.limits) - .is_err() - || launch.context.issue_workspace != launch.identity.workspace_path - || launch.context.workspace_key != launch.workspace.workspace_key() + if let Err(error) = + super::validate_launch(&launch.profile, &launch.context, &launch.limits) + { + let _ = reply.send(Err(HostError::Client(error.to_string()))); + continue; + } + if launch.context.issue_workspace != launch.identity.workspace_path + || Some(launch.context.workspace_key.as_str()) + != launch + .workspace + .workspace_path() + .file_name() + .and_then(|name| name.to_str()) { let _ = reply.send(Err(HostError::IdentityMismatch)); continue; @@ -517,14 +599,23 @@ async fn start( super::validate_launch(&launch.profile, &launch.context, &launch.limits) .map_err(|e| HostError::Client(e.to_string()))?; if launch.context.issue_workspace != launch.workspace.workspace_path() - || launch.context.workspace_key != launch.workspace.workspace_key() + || Some(launch.context.workspace_key.as_str()) + != launch + .workspace + .workspace_path() + .file_name() + .and_then(|name| name.to_str()) || launch.identity.workspace_path != launch.workspace.workspace_path() { return Err(HostError::IdentityMismatch); } - let durable = Durability::open(launch.manager, launch.workspace, launch.identity) - .await - .map_err(HostError::from)?; + let durable = if let Some(expected) = launch.expected_session_id { + Durability::open_bound_parent(launch.manager, launch.workspace, launch.identity, expected) + .await + } else { + Durability::open(launch.manager, launch.workspace, launch.identity).await + } + .map_err(HostError::from)?; let identity = durable.state().identity.clone(); let (commands, receive) = mpsc::channel(16); let (events, _) = broadcast::channel( @@ -539,6 +630,7 @@ async fn start( max_bytes: launch.limits.queued_bytes, max_frames: launch.limits.queued_frames, truncated: false, + latest_cursor: None, })); let mut handle = SessionHandle { owner_id: durable.state().owner_id.clone(), @@ -588,16 +680,16 @@ async fn start( }; // A failed connection cannot establish remote quiescence. A submitted marker // survives even if the outcome checkpoint fails, and prevents another prompt. - let checkpointed = if result - .as_ref() - .is_ok_and(|r| r.process_reaped && r.process_tree_signal_error.is_none()) - || matches!( - result, - Err(ClientError::InvalidConfiguration(_) - | ClientError::InvalidWorkspace - | ClientError::Launch(_) - | ClientError::CancelledBeforePrompt) - ) { + let checkpointed = if result.as_ref().is_ok_and(|r| { + r.process_reaped + && (r.process_tree_signal_error.is_none() || driver.durable.process_is_absent()) + }) || matches!( + result, + Err(ClientError::InvalidConfiguration(_) + | ClientError::InvalidWorkspace + | ClientError::Launch(_) + | ClientError::CancelledBeforePrompt) + ) { driver.durable.stopped().await.is_ok() } else { false @@ -617,10 +709,7 @@ async fn start( && matches!( driver.durable.state().status, AcpSessionStatus::Ready | AcpSessionStatus::Finished - ) - && result - .as_ref() - .is_ok_and(|r| r.process_reaped && r.process_tree_signal_error.is_none()); + ); let mut ended_snapshot = driver.snapshot(false); ended_snapshot.live = false; ended_snapshot.retirement_eligible = false; @@ -788,8 +877,18 @@ impl SessionDriver { } else { None }; + let model_selection = configuration + .lock() + .unwrap_or_else(|e| e.into_inner()) + .supports_model_selection(); self.durable - .ready(session_id.0.to_string(), metadata, recovery, reset_reason) + .ready( + session_id.0.to_string(), + metadata, + model_selection, + recovery, + reset_reason, + ) .await .map_err(|_| ClientError::Setup("durable session checkpoint failed".into()))?; self.publisher @@ -822,6 +921,14 @@ impl SessionDriver { let _ = pending.reply.send(Err(HostError::Client(error.to_string()))); return Err(error); } + let model_selection = configuration + .lock() + .unwrap_or_else(|e| e.into_inner()) + .supports_model_selection(); + if self.durable.set_model_selection(model_selection).await.is_err() { + let _ = pending.reply.send(Err(HostError::Persistence)); + return Err(ClientError::Setup("model capability checkpoint failed".into())); + } if self.durable.submitted(pending.run_id.clone(), pending.attempt).await.is_err() { let _ = pending.reply.send(Err(HostError::Persistence)); return Err(ClientError::Setup("submission checkpoint failed".into())); @@ -1010,26 +1117,22 @@ async fn prompt_rpc( connection.send_notification(CancelNotification::new(session_id.clone())).map_err(|_| ClientError::Protocol { submitted: true })?; tokio::time::timeout(limits.cancel_timeout, &mut response).await.map_err(|_| ClientError::CancelTimeout)? }, - _ = tokio::time::sleep(limits.prompt_timeout) => return Err(ClientError::PromptTimeout), + _ = super::wait_prompt_timeout(limits.prompt_timeout) => return Err(ClientError::PromptTimeout), }.map_err(|error| super::rpc_failure("session/prompt", &error, &capture, true).unwrap_or(ClientError::Protocol { submitted: true }))?; let stop_reason = result .get("stopReason") .and_then(serde_json::Value::as_str) - .filter(|reason| reason.len() <= 1024) - .ok_or(ClientError::Protocol { submitted: true })? - .to_owned(); - // Unknown reasons do not establish the tested stop contract. - if ![ - "end_turn", - "max_tokens", - "max_turn_requests", - "refusal", - "cancelled", - ] - .contains(&stop_reason.as_str()) - { - return Err(ClientError::Protocol { submitted: true }); - } + .filter(|reason| !reason.is_empty() && reason.len() <= 1024) + .ok_or(ClientError::Protocol { submitted: true })?; + // The captured response is redacted independently. The terminal report is + // also persisted and projected into scheduler status, so apply the same + // known-secret matcher before it crosses that boundary. + let stop_reason = capture + .lock() + .unwrap_or_else(|error| error.into_inner()) + .redact_stop_reason(stop_reason); + // A prompt response establishes delivery and a terminal peer observation. + // The worker decides which bounded reasons count as success or cancellation. services.end_turn(limits.setup_timeout).await?; Ok(TurnReport { cancellation_acknowledged: cancellation_requested && stop_reason == "cancelled", diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs index a0422392e..7f0de8a81 100644 --- a/crates/opensymphony-acp/src/lib.rs +++ b/crates/opensymphony-acp/src/lib.rs @@ -45,11 +45,21 @@ use tracing::instrument::WithSubscriber; mod atomic_file; mod durable; mod host; +mod projection; mod services; mod session_config; pub use host::*; +pub use projection::{RuntimeProjection, RuntimeUpdate, profile_capabilities, run_capability}; pub use services::HostServices; pub use session_config::SessionConfiguration; + +pub(crate) fn launch_profile_fingerprint( + profile: &AcpProfile, + services: &HostServices, + limits: &ClientLimits, +) -> Result { + durable::profile_fingerprint(profile, services, limits).map_err(|error| error.to_string()) +} #[cfg(windows)] mod windows_path; #[cfg(windows)] @@ -68,7 +78,9 @@ pub const SDK_VERSION: &str = "2.2.0"; pub const SCHEMA_VERSION: &str = "1.9.1"; /// Host-owned launch inputs; neither cwd nor resolved secrets belong in a profile. -/// `workspace_key` is the scheduler's sanitized checkout key (including repository suffixes). +/// `workspace_key` is the sanitized final directory name; `workspace_root` is its +/// immediate parent. The retained host also verifies the manager-owned handle, +/// repository binding and generation for nested or generation-suffixed workspaces. pub struct LaunchContext { pub workspace_root: PathBuf, pub workspace_key: String, @@ -98,6 +110,8 @@ pub struct ClientLimits { pub pending_callbacks: usize, pub callback_timeout: Duration, pub setup_timeout: Duration, + /// Client wall-clock bound for a turn. Zero disables this bound, allowing + /// the orchestrator's activity-based stall and abort policy to own liveness. pub prompt_timeout: Duration, pub cancel_timeout: Duration, pub reap_timeout: Duration, @@ -127,6 +141,14 @@ impl Default for ClientLimits { } } +async fn wait_prompt_timeout(timeout: Duration) { + if timeout.is_zero() { + std::future::pending().await + } else { + tokio::time::sleep(timeout).await; + } +} + #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ClientError { #[error("invalid ACP launch configuration: {0}")] @@ -371,6 +393,18 @@ struct Capture { } impl Capture { + fn redact_stop_reason(&self, reason: &str) -> String { + let redacted = self.secrets.redact(reason); + if redacted.len() > 1024 || redacted.chars().any(char::is_control) { + // Replacement can expand a bounded peer value beyond durable + // metadata limits. Preserve terminal evidence without persisting + // a partial replacement or protocol control text. + "[redacted]".into() + } else { + redacted + } + } + fn redact(&self, value: &mut Value, diagnostic: bool) { match value { Value::String(s) => { @@ -409,6 +443,27 @@ impl Capture { _ => {} } } + fn redact_frame(&self, payload: &mut Value, diagnostic: bool) { + // These numeric counters are protocol usage, not credential tokens. + // The exception is limited to a recognized prompt response shape; + // arbitrary token-named fields and configuration still use full redaction. + let usage = projection::reported_turn_usage(payload); + self.redact(payload, diagnostic); + if let Some(usage) = usage + && let Some(target) = payload + .pointer_mut("/result/usage") + .and_then(Value::as_object_mut) + { + for (name, value) in usage { + if !self.secrets.contains_secret(&name) + && !self.secrets.contains_secret(&value.to_string()) + { + target.insert(name, value); + } + } + } + } + fn record(&mut self, direction: &str, mut payload: Value) { self.sequence += 1; if payload.get("method").and_then(Value::as_str) == Some("fs/write_text_file") @@ -442,7 +497,7 @@ impl Capture { } if let Some(publisher) = &self.publisher { let mut source = payload.clone(); - self.redact(&mut source, false); + self.redact_frame(&mut source, false); publisher.publish(SourceFrame { sequence: self.sequence, direction: direction.into(), @@ -454,7 +509,7 @@ impl Capture { self.truncated = true; return; } - self.redact(&mut payload, true); + self.redact_frame(&mut payload, true); self.retain_frame(SourceFrame { sequence: self.sequence, direction: direction.into(), @@ -479,6 +534,21 @@ impl Capture { type SharedCapture = Arc>; +/// Memory access belongs to the run-scoped worker overlay, never the daemon's +/// ambient environment. This also protects terminal callbacks, which inherit +/// the validated ACP child environment. +pub(crate) fn is_reserved_memory_environment_name(name: &str) -> bool { + #[cfg(windows)] + { + name.to_ascii_uppercase() + .starts_with("OPENSYMPHONY_MEMORY_") + } + #[cfg(not(windows))] + { + name.starts_with("OPENSYMPHONY_MEMORY_") + } +} + fn capture(capture: &SharedCapture, direction: &str, payload: Value) { capture .lock() @@ -542,7 +612,6 @@ fn validate_launch( || !(1..=128).contains(&limits.pending_callbacks) || [ limits.setup_timeout, - limits.prompt_timeout, limits.cancel_timeout, limits.reap_timeout, limits.callback_timeout, @@ -603,6 +672,13 @@ fn validate_launch( .collect::>(); let mut secrets = Vec::new(); for (target, source) in &profile.env_refs { + if is_reserved_memory_environment_name(target) + || is_reserved_memory_environment_name(source) + { + return Err(ClientError::InvalidConfiguration( + "env_refs cannot remap a run-scoped memory grant".into(), + )); + } if excluded(target) || excluded(source) { return Err(ClientError::InvalidConfiguration( "env_refs cannot expose excluded checkout credentials".into(), @@ -1298,7 +1374,7 @@ async fn run_connection( } } }, - _ = tokio::time::sleep(limits.prompt_timeout) => { + _ = wait_prompt_timeout(limits.prompt_timeout) => { phase_error = Some(ClientError::PromptTimeout); return Err(agent_client_protocol::Error::internal_error()); } @@ -1306,7 +1382,9 @@ async fn run_connection( phase_error = rpc_failure("session/prompt", error, &capture_state, submitted.load(Ordering::Acquire)); })?; let stop_reason = result.get("stopReason").and_then(Value::as_str) - .ok_or_else(agent_client_protocol::Error::invalid_params)?.to_owned(); + .ok_or_else(agent_client_protocol::Error::invalid_params)?; + let stop_reason = capture_state.lock().unwrap_or_else(|error| error.into_inner()) + .redact_stop_reason(stop_reason); if let Err(error) = service_sender.end_turn(limits.setup_timeout).await { phase_error = Some(error); return Err(agent_client_protocol::Error::internal_error()); @@ -1444,6 +1522,17 @@ async fn drain_stderr(stderr: tokio::process::ChildStderr, max: usize) -> String } } +/// ACP protocol and execution remain behind this adapter boundary. +pub struct AcpAdapter; +impl crate::opensymphony_domain::HarnessAdapter for AcpAdapter { + fn harness_kind(&self) -> &'static str { + "acp" + } + fn capabilities(&self) -> crate::opensymphony_gateway_schema::capability::HarnessCapability { + crate::opensymphony_gateway_schema::capability::HarnessCapability::acp() + } +} + #[cfg(test)] mod tests { use super::*; @@ -1467,6 +1556,23 @@ mod tests { assert!(SecretRedactor::new((0..1025).map(|i| format!("secret-{i}")).collect()).is_err()); } + #[test] + fn terminal_reason_redaction_remains_durable_when_replacement_expands() { + let capture = Capture { + frames: Vec::new(), + sequence: 0, + truncated: false, + max: 0, + bytes: 0, + max_bytes: 0, + publisher: None, + secrets: SecretRedactor::new(vec!["x".into()]).expect("matcher"), + }; + assert_eq!(capture.redact_stop_reason("vendor_x"), "vendor_[redacted]"); + assert_eq!(capture.redact_stop_reason(&"x".repeat(1024)), "[redacted]"); + assert_eq!(capture.redact_stop_reason("vendor\nreason"), "[redacted]"); + } + #[test] fn queued_input_enforces_both_budgets_and_releases_dispatched_bytes() { let mut queue = QueuedInput::default(); @@ -1534,6 +1640,35 @@ mod tests { )); } + #[test] + fn usage_counters_survive_frame_redaction_without_exempting_credentials() { + let capture = Capture { + frames: Vec::new(), + sequence: 0, + truncated: false, + max: 10, + bytes: 0, + max_bytes: 1024, + publisher: None, + secrets: SecretRedactor::new(vec!["987654".into()]).expect("matcher"), + }; + for diagnostic in [false, true] { + let mut response = json!({"id": 1, "result": {"stopReason": "end_turn", "usage": { + "inputTokens": 4, "outputTokens": 2, "thoughtTokens": 987654, + "cachedReadTokens": "credential", "apiToken": "secret" + }}}); + capture.redact_frame(&mut response, diagnostic); + assert_eq!(response["result"]["usage"]["inputTokens"], 4); + assert_eq!(response["result"]["usage"]["outputTokens"], 2); + for key in ["thoughtTokens", "cachedReadTokens", "apiToken"] { + assert_eq!(response["result"]["usage"][key], "[redacted]"); + } + let mut unrelated = json!({"method": "_vendor/private", "params": {"inputTokens": 4}}); + capture.redact_frame(&mut unrelated, diagnostic); + assert_eq!(unrelated["params"]["inputTokens"], "[redacted]"); + } + } + #[test] fn evidence_byte_budget_includes_metadata_and_accepts_exact_boundary() { let frame = SourceFrame { diff --git a/crates/opensymphony-acp/src/projection.rs b/crates/opensymphony-acp/src/projection.rs new file mode 100644 index 000000000..bb3b1bf11 --- /dev/null +++ b/crates/opensymphony-acp/src/projection.rs @@ -0,0 +1,1147 @@ +//! Scheduler projection of the redacted source stream; wire evidence stays on the owner. +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Value, json}; + +use super::{SessionEvent, SourceFrame}; + +#[derive(Debug)] +pub struct RuntimeUpdate { + pub sequence: u64, + pub generation: u64, + pub observed_at: chrono::DateTime, + pub kind: String, + pub summary: Option, + pub payload: Value, +} + +pub(super) fn reported_turn_usage(payload: &Value) -> Option> { + let reason = payload.pointer("/result/stopReason")?.as_str()?; + if payload.get("method").is_some() || reason.is_empty() || reason.len() > 1024 { + return None; + } + let usage = payload.pointer("/result/usage")?.as_object()?; + let reported = usage + .iter() + .filter(|(name, value)| { + matches!( + name.as_str(), + "totalTokens" + | "inputTokens" + | "outputTokens" + | "thoughtTokens" + | "cachedReadTokens" + | "cachedWriteTokens" + ) && value.is_u64() + }) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(); + (!reported.is_empty()).then_some(reported) +} + +#[derive(Default)] +pub struct RuntimeProjection { + last: Option<(u64, u64)>, + tools: BTreeMap, + tool_bytes: usize, + prompt_request: Option, + terminal_creates: BTreeMap)>, + terminal_waits: BTreeMap, + completed_terminals: BTreeSet, + filesystem_callbacks: BTreeSet, +} + +impl RuntimeProjection { + pub fn after(cursor: (u64, u64)) -> Self { + Self { + last: Some(cursor), + ..Self::default() + } + } + + pub fn last_cursor(&self) -> Option<(u64, u64)> { + self.last + } + + pub fn apply(&mut self, event: &SessionEvent, run_id: &str) -> Option { + let SessionEvent::Source { + generation, + run_id: source_run, + replay, + frame, + } = event + else { + return None; + }; + let cursor = (*generation, frame.sequence); + if self.last.is_some_and(|last| cursor <= last) { + return None; + } + self.last = Some(cursor); + if *replay || source_run != run_id { + return None; + } + if frame.direction == "outgoing" { + if frame.payload["method"] == "session/prompt" { + self.prompt_request = frame.payload.get("id").cloned(); + } + return self + .project_filesystem_response(*generation, frame) + .or_else(|| self.project_terminal_response(*generation, frame)); + } + if frame.direction != "incoming" { + return None; + } + if let Some(activity) = self.project_filesystem_request(*generation, frame) { + return Some(activity); + } + if let Some(running_poll) = self.record_terminal_request(frame) { + return running_poll + .then(|| callback_activity(*generation, frame, "ACP terminal output polled")); + } + if frame.payload.get("method").is_none() + && self + .prompt_request + .as_ref() + .is_some_and(|id| frame.payload.get("id") == Some(id)) + { + self.prompt_request = None; + // Optional end-turn usage is an SDK extension. Preserve reported + // response counters separately from context occupancy; do not infer + // delta accumulation from the peer's response observation. + let payload = reported_turn_usage(&frame.payload)?; + return Some(RuntimeUpdate { + sequence: frame.sequence, + generation: *generation, + observed_at: frame.observed_at, + kind: "turn_usage".into(), + summary: Some("ACP turn usage reported".into()), + payload: Value::Object(payload), + }); + } + self.project(*generation, frame) + } + + fn project_filesystem_request( + &mut self, + generation: u64, + frame: &SourceFrame, + ) -> Option { + let method = frame.payload.get("method")?.as_str()?; + if !matches!(method, "fs/read_text_file" | "fs/write_text_file") { + return None; + } + if let Some(id) = frame + .payload + .get("id") + .and_then(rpc_id) + .filter(|id| id.len() <= 256) + { + if self.filesystem_callbacks.len() >= 1024 { + self.filesystem_callbacks.pop_first(); + } + self.filesystem_callbacks.insert(id); + } + Some(callback_activity( + generation, + frame, + "ACP filesystem callback requested", + )) + } + + fn project_filesystem_response( + &mut self, + generation: u64, + frame: &SourceFrame, + ) -> Option { + if frame.payload.get("method").is_some() { + return None; + } + let id = frame.payload.get("id").and_then(rpc_id)?; + self.filesystem_callbacks + .remove(&id) + .then(|| callback_activity(generation, frame, "ACP filesystem callback completed")) + } + + // Some(true) means a running output poll can advance scheduler liveness. + // Other terminal requests are consumed without a projected event. + fn record_terminal_request(&mut self, frame: &SourceFrame) -> Option { + let method = frame.payload.get("method").and_then(Value::as_str)?; + if !matches!( + method, + "terminal/create" | "terminal/wait_for_exit" | "terminal/output" + ) { + return None; + } + let Some(id) = frame.payload.get("id").and_then(rpc_id) else { + return Some(false); + }; + if method == "terminal/create" { + let Some(command) = frame + .payload + .pointer("/params/command") + .and_then(Value::as_str) + else { + return Some(false); + }; + let args = match frame.payload.pointer("/params/args") { + Some(Value::Array(args)) => args.as_slice(), + None => &[], + _ => return Some(false), + }; + let Some(command) = terminal_command(command, args) else { + return Some(false); + }; + let cwd = frame + .payload + .pointer("/params/cwd") + .and_then(Value::as_str) + .map(str::to_owned); + if self.terminal_creates.len() >= 1024 { + self.terminal_creates.pop_first(); + } + self.terminal_creates.insert(id, (command, cwd)); + } else if let Some(terminal_id) = frame + .payload + .pointer("/params/terminalId") + .and_then(Value::as_str) + { + let running_poll = + method == "terminal/output" && !self.completed_terminals.contains(terminal_id); + if self.terminal_waits.len() >= 1024 { + self.terminal_waits.pop_first(); + } + self.terminal_waits + .insert(id, (terminal_id.to_owned(), method == "terminal/output")); + return Some(running_poll); + } + Some(false) + } + + fn project_terminal_response( + &mut self, + generation: u64, + frame: &SourceFrame, + ) -> Option { + if frame.payload.get("method").is_some() { + return None; + } + let id = frame.payload.get("id").and_then(rpc_id)?; + if let Some((command, cwd)) = self.terminal_creates.remove(&id) { + let terminal_id = frame + .payload + .pointer("/result/terminalId") + .and_then(Value::as_str)?; + return Some(RuntimeUpdate { + sequence: frame.sequence, + generation, + observed_at: frame.observed_at, + kind: "command_started".into(), + summary: Some("ACP terminal started".into()), + payload: json!({"command_id":terminal_id,"command":command,"cwd":cwd}), + }); + } + let (terminal_id, output_poll) = self.terminal_waits.remove(&id)?; + if self.completed_terminals.contains(&terminal_id) { + return None; + } + if output_poll + && frame + .payload + .pointer("/result/exitStatus") + .is_some_and(Value::is_null) + { + return Some(callback_activity( + generation, + frame, + "ACP terminal output received", + )); + } + let exit_code = frame + .payload + .pointer("/result/exitCode") + .or_else(|| frame.payload.pointer("/result/exitStatus/exitCode")) + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok())?; + if self.completed_terminals.len() >= 1024 { + self.completed_terminals.pop_first(); + } + self.completed_terminals.insert(terminal_id.clone()); + Some(RuntimeUpdate { + sequence: frame.sequence, + generation, + observed_at: frame.observed_at, + kind: "command_finished".into(), + summary: Some("ACP terminal finished".into()), + payload: json!({"command_id":terminal_id,"exit_code":exit_code}), + }) + } + + fn project(&mut self, generation: u64, frame: &SourceFrame) -> Option { + let method = frame.payload.get("method")?.as_str()?; + if method == "session/request_permission" { + return Some(RuntimeUpdate { + sequence: frame.sequence, + generation, + observed_at: frame.observed_at, + kind: "waiting_for_input".into(), + summary: Some( + "ACP agent requested permission; operator responses are unavailable".into(), + ), + payload: json!({"reason":"permission_request", "operator_responses":false}), + }); + } + if method != "session/update" { + return None; + } + let mut update = frame.payload.pointer("/params/update")?.clone(); + let kind = update.get("sessionUpdate")?.as_str()?.to_owned(); + let summary = match kind.as_str() { + "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk" => update + .pointer("/content/text") + .and_then(Value::as_str) + .map(str::to_owned), + "tool_call" | "tool_call_update" => { + let id = update.get("toolCallId")?.as_str()?.to_owned(); + // Keep partial tool updates merged while bounding aggregate retained state. + let mut merged = self.tools.get(&id).cloned().unwrap_or_else(|| json!({})); + for (key, value) in update.as_object()? { + if !value.is_null() { + merged[key] = value.clone(); + } + } + let bytes = serde_json::to_vec(&merged).ok()?.len(); + const MAX_TOOL_BYTES: usize = 1024 * 1024; + if bytes > MAX_TOOL_BYTES { + return None; + } + if let Some(previous) = self.tools.remove(&id) { + self.tool_bytes -= serde_json::to_vec(&previous).ok()?.len(); + } + while self.tools.len() >= 1024 || self.tool_bytes + bytes > MAX_TOOL_BYTES { + let (_, removed) = self.tools.pop_first()?; + self.tool_bytes -= serde_json::to_vec(&removed).ok()?.len(); + } + self.tool_bytes += bytes; + self.tools.insert(id, merged.clone()); + update = merged; + update + .get("title") + .and_then(Value::as_str) + .map(str::to_owned) + } + "plan" + | "usage_update" + | "available_commands_update" + | "current_mode_update" + | "config_option_update" => None, + _ => { + // Source frames are already redacted by Capture. Keep future + // update shapes visible as activity without letting an + // unrecognized payload grow scheduler storage without bound. + const MAX_UNKNOWN_UPDATE_BYTES: usize = 16 * 1024; + let payload = if serde_json::to_vec(&update).ok()?.len() <= MAX_UNKNOWN_UPDATE_BYTES + { + update + } else { + json!({ + "sessionUpdate": kind.chars().take(128).collect::(), + "truncated": true + }) + }; + return Some(RuntimeUpdate { + sequence: frame.sequence, + generation, + observed_at: frame.observed_at, + kind: "session_update".into(), + summary: Some("ACP session update".into()), + payload, + }); + } + }; + // Activity storage requires a summary; structural updates must remain + // visible even when the peer sends no text or title. + let summary = summary.or_else(|| { + Some( + match kind.as_str() { + "agent_message_chunk" => "ACP agent message", + "agent_thought_chunk" => "ACP agent reasoning", + "user_message_chunk" => "ACP user message", + "tool_call" | "tool_call_update" => "ACP tool updated", + "plan" => "ACP plan updated", + "usage_update" => "ACP context usage updated", + "available_commands_update" => "ACP commands updated", + "current_mode_update" => "ACP mode updated", + "config_option_update" => "ACP configuration updated", + _ => unreachable!("known projection kind"), + } + .into(), + ) + }); + // usage_update describes context occupancy/cost, not input/output token deltas. + // Keep its optional fields intact instead of charging invented token counters. + Some(RuntimeUpdate { + sequence: frame.sequence, + generation, + observed_at: frame.observed_at, + kind, + summary, + payload: update, + }) + } +} + +fn callback_activity(generation: u64, frame: &SourceFrame, summary: &str) -> RuntimeUpdate { + RuntimeUpdate { + sequence: frame.sequence, + generation, + observed_at: frame.observed_at, + kind: "callback_activity".into(), + summary: Some(summary.into()), + payload: Value::Null, + } +} + +fn rpc_id(value: &Value) -> Option { + (value.is_string() || value.is_number()) + .then(|| serde_json::to_string(value).ok()) + .flatten() +} + +fn terminal_command(command: &str, args: &[Value]) -> Option { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(command.to_owned()); + for arg in args { + parts.push(arg.as_str()?.to_owned()); + } + let rendered = parts + .iter() + .map(|part| { + if part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-._/:=@".contains(&byte)) + { + part.clone() + } else { + format!("'{}'", part.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" "); + (rendered.len() <= 8192).then_some(rendered) +} + +pub fn run_capability( + state: &crate::opensymphony_workspace::AcpSessionState, +) -> crate::opensymphony_gateway_schema::capability::HarnessRunCapability { + let caps = &state.initialization["agentCapabilities"]; + crate::opensymphony_gateway_schema::capability::HarnessRunCapability { + harness: "acp".into(), + profile_id: state.identity.profile_id.clone(), + protocol: "acp".into(), + protocol_version: 1, + rpc: "json_rpc_2_0".into(), + encoding: "utf-8".into(), + framing: "lf".into(), + carrier: "stdio".into(), + session_restore: caps["loadSession"].as_bool() == Some(true) + || caps + .pointer("/sessionCapabilities/resume") + .is_some_and(|v| !v.is_null()), + history_replay: caps["loadSession"].as_bool() == Some(true), + model_selection: state.model_selection, + cancellation: true, + operator_responses: false, + } +} + +pub fn profile_capabilities( + config: &crate::opensymphony_workflow::AcpConfig, + worker_environment: &BTreeMap, + excluded_environment: &BTreeSet, +) -> Vec { + let mut environment = std::env::vars_os() + .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))) + .filter(|(name, _)| !super::is_reserved_memory_environment_name(name)) + .collect::>(); + for (name, value) in worker_environment + .iter() + .filter(|(name, _)| !super::is_reserved_memory_environment_name(name)) + { + crate::opensymphony_workspace::insert_environment_value( + &mut environment, + name.clone(), + value.clone(), + ); + } + profile_capabilities_with_environment(config, &environment, excluded_environment) +} + +fn profile_capabilities_with_environment( + config: &crate::opensymphony_workflow::AcpConfig, + environment: &BTreeMap, + excluded_environment: &BTreeSet, +) -> Vec { + use crate::opensymphony_workspace::{ + environment_variable_names_equal, has_environment_name_collision, + }; + let value = |name: &str| { + environment + .iter() + .find(|(key, _)| environment_variable_names_equal(key, name)) + .map(|(_, value)| value) + }; + let excluded = |name: &str| { + excluded_environment + .iter() + .any(|key| environment_variable_names_equal(key, name)) + }; + config + .profiles + .iter() + .map(|(id, profile)| { + // Launch replaces mapped targets and removes source-only variables. + // Resolve PATH with the same precedence before advertising readiness. + let path = match profile + .env_refs + .iter() + .find(|(target, _)| environment_variable_names_equal(target, "PATH")) + { + Some((_, source)) => value(source), + None if profile + .env_refs + .values() + .any(|source| environment_variable_names_equal(source, "PATH")) => + { + None + } + None => value("PATH"), + }; + let command = std::path::Path::new(&profile.command); + let available = if command.components().count() > 1 { + command.is_absolute() && executable(command) + } else { + path.is_some_and(|paths| { + std::env::split_paths(paths).any(|path| { + // A profile preflight has no issue cwd yet. + path.is_absolute() && executable(&path.join(command)) + }) + }) + }; + let reason = if profile.validate().is_err() + || has_environment_name_collision(profile.env_refs.keys().map(String::as_str)) + || profile.env_refs.iter().any(|(target, source)| { + super::is_reserved_memory_environment_name(target) + || super::is_reserved_memory_environment_name(source) + }) { + Some("invalid_profile") + } else if profile + .env_refs + .iter() + .any(|(target, source)| excluded(target) || excluded(source)) + { + Some("credential_reference_excluded") + } else if profile.env_refs.values().any(|name| { + value(name).is_none_or(|value| value.is_empty() || value.contains('\0')) + }) { + Some("credential_reference_unavailable") + } else if !available { + Some("executable_unavailable") + } else { + None + }; + crate::opensymphony_gateway_schema::capability::HarnessProfileCapability { + harness: "acp".into(), + profile_id: id.clone(), + preflight_ready: reason.is_none(), + unavailable_reason: reason.map(str::to_owned), + } + }) + .collect() +} + +fn executable(path: &std::path::Path) -> bool { + #[cfg(windows)] + if path.extension().is_none() && path.with_extension("exe").is_file() { + return true; + } + let Ok(metadata) = path.metadata() else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_preflight_resolves_mapped_path_and_rejects_missing_references() { + let temp = tempfile::tempdir().expect("temporary executable directory"); + let command = if cfg!(windows) { "peer.exe" } else { "peer" }; + let path = temp.path().join(command); + std::fs::write(&path, "fixture").expect("executable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("executable permissions"); + } + let mut config: crate::opensymphony_workflow::AcpConfig = serde_json::from_value(json!({ + "profiles": {"profile": {"command": "peer", "env_refs": {"PATH": "ACP_PATH"}}} + })) + .expect("profile"); + let environment = BTreeMap::from([ + ( + "PATH".into(), + temp.path().join("missing").to_string_lossy().into_owned(), + ), + ( + "ACP_PATH".into(), + temp.path().to_string_lossy().into_owned(), + ), + ]); + assert!( + profile_capabilities_with_environment(&config, &environment, &BTreeSet::new())[0] + .preflight_ready + ); + let profile = config.profiles.get_mut("profile").expect("profile"); + profile.env_refs.insert("PATH".into(), "MISSING".into()); + assert_eq!( + profile_capabilities_with_environment(&config, &environment, &BTreeSet::new())[0] + .unavailable_reason + .as_deref(), + Some("credential_reference_unavailable") + ); + let profile = config.profiles.get_mut("profile").expect("profile"); + profile.env_refs = BTreeMap::from([("OTHER".into(), "PATH".into())]); + let environment = + BTreeMap::from([("PATH".into(), temp.path().to_string_lossy().into_owned())]); + assert_eq!( + profile_capabilities_with_environment(&config, &environment, &BTreeSet::new())[0] + .unavailable_reason + .as_deref(), + Some("executable_unavailable") + ); + } + + #[test] + fn profile_preflight_includes_resolved_worker_environment() { + let source = "OPENSYMPHONY_ACP_PREFLIGHT_OVERLAY_ONLY_TEST"; + let config: crate::opensymphony_workflow::AcpConfig = serde_json::from_value(json!({ + "profiles": { + "profile": { + "command": std::env::current_exe().expect("test executable"), + "env_refs": {"LINEAR_API_KEY": source} + } + } + })) + .expect("profile"); + assert_eq!( + profile_capabilities(&config, &BTreeMap::new(), &BTreeSet::new())[0] + .unavailable_reason + .as_deref(), + Some("credential_reference_unavailable") + ); + let overlay = BTreeMap::from([(source.into(), "resolved-worker-token".into())]); + assert!(profile_capabilities(&config, &overlay, &BTreeSet::new())[0].preflight_ready); + } + + #[test] + fn profile_preflight_rejects_excluded_checkout_credential_mapping() { + let config: crate::opensymphony_workflow::AcpConfig = serde_json::from_value(json!({ + "profiles": { + "profile": { + "command": std::env::current_exe().expect("test executable"), + "env_refs": {"ACP_AUTH": "GITHUB_TOKEN"} + } + } + })) + .expect("profile"); + let overlay = BTreeMap::from([("GITHUB_TOKEN".into(), "checkout-only".into())]); + assert!(profile_capabilities(&config, &overlay, &BTreeSet::new())[0].preflight_ready); + let excluded = BTreeSet::from(["GITHUB_TOKEN".into()]); + let capability = &profile_capabilities(&config, &overlay, &excluded)[0]; + assert!(!capability.preflight_ready); + assert_eq!( + capability.unavailable_reason.as_deref(), + Some("credential_reference_excluded") + ); + let excluded_target = BTreeSet::from(["ACP_AUTH".into()]); + assert_eq!( + profile_capabilities(&config, &overlay, &excluded_target)[0] + .unavailable_reason + .as_deref(), + Some("credential_reference_excluded") + ); + } + + #[test] + fn profile_preflight_cannot_map_into_reserved_memory_scope() { + let mut config: crate::opensymphony_workflow::AcpConfig = serde_json::from_value(json!({ + "profiles": { + "profile": { + "command": std::env::current_exe().expect("test executable"), + "env_refs": {"OPENSYMPHONY_MEMORY_ADMIN_TOKEN": "AGENT_TOKEN"} + } + } + })) + .expect("profile"); + let overlay = BTreeMap::from([("AGENT_TOKEN".into(), "unscoped-bearer".into())]); + let capability = &profile_capabilities(&config, &overlay, &BTreeSet::new())[0]; + assert_eq!( + capability.unavailable_reason.as_deref(), + Some("invalid_profile") + ); + config + .profiles + .get_mut("profile") + .expect("profile") + .env_refs = + BTreeMap::from([("AGENT_TOKEN".into(), "OPENSYMPHONY_MEMORY_TOKEN".into())]); + let capability = &profile_capabilities(&config, &overlay, &BTreeSet::new())[0]; + assert_eq!( + capability.unavailable_reason.as_deref(), + Some("invalid_profile") + ); + } + + fn event(sequence: u64, replay: bool, update: Value) -> SessionEvent { + SessionEvent::Source { + generation: 1, + run_id: "run".into(), + replay, + frame: SourceFrame { + sequence, + direction: "incoming".into(), + observed_at: chrono::Utc::now(), + payload: json!({"method":"session/update","params":{"update":update}}), + }, + } + } + + fn callback_event(sequence: u64, direction: &str, payload: Value) -> SessionEvent { + SessionEvent::Source { + generation: 1, + run_id: "run".into(), + replay: false, + frame: SourceFrame { + sequence, + direction: direction.into(), + observed_at: chrono::Utc::now(), + payload, + }, + } + } + + fn timed_callback_event( + sequence: u64, + observed_ms: i64, + direction: &str, + payload: Value, + ) -> SessionEvent { + let mut event = callback_event(sequence, direction, payload); + let SessionEvent::Source { frame, .. } = &mut event else { + unreachable!("callback fixture is a source event") + }; + frame.observed_at = + chrono::DateTime::from_timestamp_millis(observed_ms).expect("fixture timestamp"); + event + } + + #[test] + fn terminal_callback_responses_project_completed_command_once() { + let mut projection = RuntimeProjection::default(); + let create = callback_event( + 1, + "incoming", + json!({"id":1,"method":"terminal/create","params":{"command":"cargo","args":["test"],"cwd":"/workspace/repositories/one"}}), + ); + assert!(projection.apply(&create, "run").is_none()); + let started = projection + .apply( + &callback_event( + 2, + "outgoing", + json!({"id":1,"result":{"terminalId":"terminal-1"}}), + ), + "run", + ) + .expect("command start"); + assert_eq!(started.kind, "command_started"); + assert_eq!(started.payload["command"], "cargo test"); + assert_eq!(started.payload["cwd"], "/workspace/repositories/one"); + assert!(projection.apply(&create, "run").is_none()); + + assert!(projection.apply(&callback_event(3, "incoming", json!({"id":2,"method":"terminal/wait_for_exit","params":{"terminalId":"terminal-1"}})), "run").is_none()); + let finished = projection + .apply( + &callback_event(4, "outgoing", json!({"id":2,"result":{"exitCode":0}})), + "run", + ) + .expect("command finish"); + assert_eq!(finished.kind, "command_finished"); + assert_eq!( + finished.payload, + json!({"command_id":"terminal-1","exit_code":0}) + ); + + assert!(projection.apply(&callback_event(5, "incoming", json!({"id":3,"method":"terminal/output","params":{"terminalId":"terminal-1"}})), "run").is_none()); + assert!( + projection + .apply( + &callback_event( + 6, + "outgoing", + json!({"id":3,"result":{"exitStatus":{"exitCode":0}}}) + ), + "run" + ) + .is_none() + ); + assert!( + projection + .apply( + &callback_event( + 7, + "incoming", + json!({"id":4,"method":"terminal/create","params":{"command":"false"}}) + ), + "run" + ) + .is_none() + ); + assert!( + projection + .apply( + &callback_event(8, "outgoing", json!({"id":4,"error":{"code":-32603}})), + "run" + ) + .is_none() + ); + assert!( + projection + .apply( + &callback_event( + 9, + "incoming", + json!({"id":5,"method":"terminal/create","params":{"command":"true"}}) + ), + "run" + ) + .is_none() + ); + assert!( + projection + .apply( + &callback_event( + 10, + "outgoing", + json!({"id":5,"result":{"terminalId":"terminal-2"}}) + ), + "run" + ) + .is_some() + ); + } + + #[test] + fn running_terminal_output_polls_keep_idle_deadline_sliding_without_completion() { + use crate::opensymphony_domain::{DurationMs, StallMetadata, TimestampMs}; + + let mut projection = RuntimeProjection::default(); + let mut stall = StallMetadata::new(TimestampMs::new(0), DurationMs::new(1_000)); + assert!( + projection + .apply( + &timed_callback_event( + 1, + 0, + "incoming", + json!({ + "id": 1, "method": "terminal/create", + "params": {"command": "cargo", "args": ["test"]} + }) + ), + "run" + ) + .is_none() + ); + let started = projection + .apply( + &timed_callback_event( + 2, + 0, + "outgoing", + json!({ + "id": 1, "result": {"terminalId": "terminal-1"} + }), + ), + "run", + ) + .expect("command start"); + assert_eq!(started.kind, "command_started"); + stall.observe_activity(TimestampMs::new(0)); + + for (request_sequence, observed_ms) in [(3, 500), (5, 900), (7, 1_700)] { + let request = projection + .apply( + &timed_callback_event( + request_sequence, + observed_ms, + "incoming", + json!({ + "id": request_sequence, "method": "terminal/output", + "params": {"terminalId": "terminal-1"} + }), + ), + "run", + ) + .expect("running poll request activity"); + assert_eq!(request.kind, "callback_activity"); + assert_eq!(request.payload, Value::Null); + stall.observe_activity(TimestampMs::new(observed_ms as u64)); + + let response = projection + .apply( + &timed_callback_event( + request_sequence + 1, + observed_ms + 1, + "outgoing", + json!({ + "id": request_sequence, + "result": {"output": "private build output", "exitStatus": null} + }), + ), + "run", + ) + .expect("running poll response activity"); + assert_eq!(response.kind, "callback_activity"); + assert_eq!(response.payload, Value::Null); + assert!( + !response + .summary + .as_deref() + .unwrap_or_default() + .contains("private") + ); + stall.observe_activity(TimestampMs::new((observed_ms + 1) as u64)); + } + assert_eq!(stall.last_activity_at, TimestampMs::new(1_701)); + assert!(!stall.is_stalled_at(TimestampMs::new(2_000))); + assert!( + projection + .apply( + &timed_callback_event( + 9, + 2_100, + "incoming", + json!({ + "id": 9, "method": "terminal/output", + "params": {"terminalId": "terminal-1"} + }) + ), + "run" + ) + .is_some() + ); + let finished = projection + .apply( + &timed_callback_event(10, 2_101, "outgoing", json!({ + "id": 9, "result": {"output": "private build output", "exitStatus": {"exitCode": 0}} + })), + "run", + ) + .expect("actual exit receipt"); + assert_eq!(finished.kind, "command_finished"); + assert_eq!( + finished.payload, + json!({"command_id":"terminal-1","exit_code":0}) + ); + assert!( + projection + .apply( + &timed_callback_event( + 11, + 2_102, + "incoming", + json!({ + "id": 11, "method": "terminal/output", + "params": {"terminalId": "terminal-1"} + }) + ), + "run" + ) + .is_none() + ); + } + + #[test] + fn filesystem_callbacks_project_payload_free_scheduler_activity() { + let mut projection = RuntimeProjection::default(); + for (request_sequence, method) in [(1, "fs/read_text_file"), (3, "fs/write_text_file")] { + let request = callback_event( + request_sequence, + "incoming", + json!({"id":request_sequence,"method":method,"params":{"path":"/private/file","content":"private payload"}}), + ); + let activity = projection.apply(&request, "run").expect("callback request"); + assert_eq!(activity.kind, "callback_activity"); + assert_eq!( + activity.summary.as_deref(), + Some("ACP filesystem callback requested") + ); + assert!(activity.payload.is_null()); + let response = callback_event( + request_sequence + 1, + "outgoing", + json!({"id":request_sequence,"result":{"content":"private payload"}}), + ); + let activity = projection + .apply(&response, "run") + .expect("callback response"); + assert_eq!(activity.kind, "callback_activity"); + assert_eq!( + activity.summary.as_deref(), + Some("ACP filesystem callback completed") + ); + assert!(activity.payload.is_null()); + } + assert!( + projection + .apply( + &callback_event( + 5, + "outgoing", + json!({"id":1,"result":{"content":"private payload"}}) + ), + "run" + ) + .is_none() + ); + let oversized_id = "x".repeat(1024); + assert!( + projection + .apply( + &callback_event( + 6, + "incoming", + json!({"id":oversized_id,"method":"fs/read_text_file"}) + ), + "run" + ) + .is_some() + ); + assert!( + projection + .apply( + &callback_event(7, "outgoing", json!({"id":oversized_id,"result":{}})), + "run" + ) + .is_none() + ); + } + #[test] + fn prompt_usage_preserves_absence_and_ignores_replayed_responses() { + let mut projection = RuntimeProjection::default(); + let mut request = event(1, false, Value::Null); + if let SessionEvent::Source { frame, .. } = &mut request { + frame.direction = "outgoing".into(); + frame.payload = json!({"id":"prompt", "method":"session/prompt"}); + } + assert!(projection.apply(&request, "run").is_none()); + let mut response = event(2, false, Value::Null); + if let SessionEvent::Source { frame, .. } = &mut response { + frame.payload = json!({"id":"prompt", "result":{"stopReason":"end_turn", "usage":{"inputTokens":4,"outputTokens":2,"totalTokens":6}}}); + } + let usage = projection.apply(&response, "run").expect("usage"); + assert_eq!(usage.kind, "turn_usage"); + assert!(usage.payload.get("cachedReadTokens").is_none()); + assert!(projection.apply(&response, "run").is_none()); + if let SessionEvent::Source { frame, replay, .. } = &mut response { + frame.sequence = 3; + *replay = true; + } + assert!(projection.apply(&response, "run").is_none()); + let future_usage = reported_turn_usage( + &json!({"result":{"stopReason":"future_stop_reason","usage":{"inputTokens":3}}}), + ) + .expect("future terminal usage"); + assert_eq!(future_usage["inputTokens"], 3); + } + + #[test] + fn partial_tools_replay_and_optional_usage() { + let mut projection = RuntimeProjection::default(); + let initial = event( + 1, + false, + json!({"sessionUpdate":"tool_call","toolCallId":"t","title":"Read","status":"in_progress","content":[1]}), + ); + assert!(projection.apply(&initial, "run").is_some()); + assert!(projection.apply(&initial, "run").is_none()); + let merged = projection.apply(&event(2, false, json!({"sessionUpdate":"tool_call_update","toolCallId":"t","status":"completed","title":null})), "run").expect("projected update"); + assert_eq!(merged.payload["title"], "Read"); + assert_eq!(merged.payload["content"], json!([1])); + assert!( + projection + .apply( + &event( + 3, + true, + json!({"sessionUpdate":"usage_update","used":42,"size":100}) + ), + "run" + ) + .is_none() + ); + let usage = projection + .apply( + &event( + 4, + false, + json!({"sessionUpdate":"usage_update","used":42,"size":100}), + ), + "run", + ) + .expect("projected update"); + assert!(usage.payload.get("inputTokens").is_none()); + } + + #[test] + fn unknown_session_updates_remain_bounded_scheduler_activity() { + let mut projection = RuntimeProjection::default(); + let unknown = event( + 1, + false, + json!({"sessionUpdate":"vendor_progress","detail":{"status":"working","credential":"[redacted]"}}), + ); + let projected = projection.apply(&unknown, "run").expect("generic activity"); + assert_eq!(projected.kind, "session_update"); + assert_eq!(projected.summary.as_deref(), Some("ACP session update")); + assert_eq!(projected.payload["sessionUpdate"], "vendor_progress"); + assert_eq!(projected.payload["detail"]["credential"], "[redacted]"); + assert!(projection.apply(&unknown, "run").is_none(), "deduplicated"); + + let large = event( + 2, + false, + json!({"sessionUpdate":"vendor_progress","data":"x".repeat(20 * 1024)}), + ); + let projected = projection.apply(&large, "run").expect("bounded activity"); + assert_eq!(projected.kind, "session_update"); + assert_eq!(projected.payload["truncated"], true); + assert!(serde_json::to_vec(&projected.payload).expect("JSON").len() < 1024); + } +} diff --git a/crates/opensymphony-acp/src/services.rs b/crates/opensymphony-acp/src/services.rs index 2cc5b5af4..798f7d1c6 100644 --- a/crates/opensymphony-acp/src/services.rs +++ b/crates/opensymphony-acp/src/services.rs @@ -7,7 +7,9 @@ use crate::opensymphony_workspace::{ }; use agent_client_protocol::{ Error, Responder, - schema::v1::{ClientCapabilities, FileSystemCapabilities, McpServer}, + schema::v1::{ + ClientCapabilities, FileSystemCapabilities, HttpHeader, McpServer, McpServerHttp, + }, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -35,6 +37,17 @@ pub struct HostServices { pub mcp_servers: Vec, } impl HostServices { + pub fn attach_scoped_memory(&mut self, endpoint: &str, token: Option<&str>) { + let mut server = McpServerHttp::new("opensymphony-memory", endpoint); + if let Some(token) = token { + server = server.headers(vec![HttpHeader::new( + "Authorization", + format!("Bearer {token}"), + )]); + } + self.mcp_servers.push(McpServer::Http(server)); + } + pub(super) fn capabilities(&self) -> ClientCapabilities { ClientCapabilities::default() .fs(FileSystemCapabilities::default() diff --git a/crates/opensymphony-acp/src/session_config.rs b/crates/opensymphony-acp/src/session_config.rs index 92fddfbcb..d10e43702 100644 --- a/crates/opensymphony-acp/src/session_config.rs +++ b/crates/opensymphony-acp/src/session_config.rs @@ -22,6 +22,14 @@ impl std::fmt::Debug for SessionConfiguration { } } impl SessionConfiguration { + pub(super) fn supports_model_selection(&self) -> bool { + self.options.iter().any(|option| { + option.get("category").and_then(Value::as_str) == Some("model") + && option.get("type").and_then(Value::as_str) == Some("select") + && option.get("options").and_then(Value::as_array).is_some() + }) + } + pub(super) fn initial(&mut self, session: &Value) { self.options = session .get("configOptions") diff --git a/crates/opensymphony-cli/src/lib.rs b/crates/opensymphony-cli/src/lib.rs index ef89c7006..bff7de43b 100644 --- a/crates/opensymphony-cli/src/lib.rs +++ b/crates/opensymphony-cli/src/lib.rs @@ -2056,6 +2056,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { }; let issues = vec![ IssueSnapshot { + harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), tracker_state: "In Progress".to_owned(), @@ -2108,6 +2109,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { operator: None, }, IssueSnapshot { + harness_capability: None, identifier: "OSYM-401".to_owned(), title: "Control-plane API and snapshot store".to_owned(), tracker_state: "Done".to_owned(), @@ -2156,6 +2158,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { operator: None, }, IssueSnapshot { + harness_capability: None, identifier: "OSYM-402".to_owned(), title: "FrankenTUI operator client".to_owned(), tracker_state: "In Progress".to_owned(), diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index 8f6c721fc..797a489ca 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -2545,6 +2545,15 @@ pub(crate) struct MemoryScopeGrant { } impl MemoryScopeGrantRegistry { + #[cfg(test)] + pub(crate) fn fresh_conversation_required(&self, issue: &str) -> bool { + self.state + .read() + .expect("memory grant registry poisoned") + .revoked_issues + .contains(issue) + } + #[cfg(test)] pub(crate) fn issue_or_refresh_with_lifecycle( &self, @@ -15097,6 +15106,7 @@ Public memory concept. repository_binding: None, runtime_envelope: Some(runtime_envelope), parent_runtime_envelope: None, + acp_route: None, attempt: 2, normal_retry_count: 0, pending_retry: false, diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index a174d1781..5e7395870 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -1,5 +1,7 @@ //! Runtime backend adapters for tracker, workspace, and worker orchestration. +mod acp; + use futures_util::{StreamExt, stream}; use serde::de::DeserializeOwned; use sha2::{Digest, Sha256}; @@ -50,7 +52,7 @@ use crate::opensymphony_orchestrator::{ }; use crate::opensymphony_workflow::{Environment, ProcessEnvironment, ResolvedWorkflow}; use crate::opensymphony_workspace::{ - CheckoutRepository, CleanupConfig, HookConfig, HookDefinition, IssueDescriptor, + AcpRunRoute, CheckoutRepository, CleanupConfig, HookConfig, HookDefinition, IssueDescriptor, IssueLifecycleState, ParentCheckoutRequest, ParentRuntimeDescriptor, ParentRuntimeEnvelope, RunDescriptor, RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, WorkspaceHandle, WorkspaceManager, WorkspaceManagerConfig, @@ -90,6 +92,47 @@ const CODEX_TERMINAL_TIMEOUT: Duration = Duration::from_secs(300); const CODEX_STDERR_TAIL_LINES: usize = 20; const CODEX_SCHEMA_STDERR_PREVIEW_CHARS: usize = 500; const OPENHANDS_AGENT_SERVER_KIND: &str = "openhands_agent_server"; + +fn acp_run_route(route: &crate::opensymphony_orchestrator::HarnessRouteDecision) -> AcpRunRoute { + AcpRunRoute { + task_type: route.task_type.clone(), + harness_kind: route.harness_kind.clone(), + harness_profile: route.harness_profile.clone(), + model: route.model.clone(), + model_profile: route.model_profile.clone(), + reason: route.reason.clone(), + dry_run: route.dry_run, + user_override: route.user_override, + } +} + +fn bind_acp_profile_model( + route: &mut crate::opensymphony_orchestrator::HarnessRouteDecision, + workflow: &ResolvedWorkflow, +) { + if route.harness_kind == acp::KIND && route.model.is_none() { + route.model = route + .harness_profile + .as_ref() + .and_then(|profile| workflow.extensions.acp.profiles.get(profile)) + .and_then(|profile| profile.session.model.clone()); + } +} + +fn harness_route_from_acp_run_route( + route: AcpRunRoute, +) -> crate::opensymphony_orchestrator::HarnessRouteDecision { + crate::opensymphony_orchestrator::HarnessRouteDecision { + task_type: route.task_type, + harness_kind: route.harness_kind, + harness_profile: route.harness_profile, + model: route.model, + model_profile: route.model_profile, + reason: route.reason, + dry_run: route.dry_run, + user_override: route.user_override, + } +} const PARENT_FINAL_VERIFICATION_PATH: &str = "evidence/final-verification.json"; const MAX_PARENT_VERIFICATION_RECEIPT_BYTES: u64 = 64 * 1024; @@ -438,12 +481,13 @@ pub(super) struct RuntimeWorkspaceBackend { terminal_cleanup_paths: HashSet, recovered_run_started_at: BTreeMap, codex_bin: String, + acp_host: Option, retain_failed: bool, retry_state_root: PathBuf, } pub(super) struct RuntimeWorkerBackend { - client: OpenHandsClient, + client: Option, workflow: Arc, workspace_manager: Arc, openhands_conversation_store: Option, @@ -456,6 +500,8 @@ pub(super) struct RuntimeWorkerBackend { codex_bin: String, codex_schema_validators: CodexSchemaValidatorCache, codex_interrupts: CodexInterruptRegistry, + acp_host: Option, + acp_active: acp::ActiveSessions, launch_timeout: Duration, updates_tx: mpsc::UnboundedSender, updates_rx: mpsc::UnboundedReceiver, @@ -729,7 +775,7 @@ async fn migrate_legacy_workspace_conversations( report.skipped_without_manifest += 1; continue; }; - let manifest = match serde_json::from_str::(&raw_manifest) { + let manifest = match acp::conversation_view(&raw_manifest) { Ok(manifest) => manifest, Err(error) => { report.skipped_invalid_manifest += 1; @@ -742,7 +788,7 @@ async fn migrate_legacy_workspace_conversations( continue; } }; - if conversation_manifest_is_codex(&manifest) { + if recovered_harness_kind_from_manifest(&manifest) != OPENHANDS_AGENT_SERVER_KIND { continue; } if workspace.checkout_generation().is_some() @@ -819,7 +865,7 @@ async fn prepare_active_conversation_store_for_issues( report.skipped_without_manifest += 1; continue; }; - let manifest = match serde_json::from_str::(&raw_manifest) { + let manifest = match acp::conversation_view(&raw_manifest) { Ok(manifest) => manifest, Err(error) => { report.skipped_invalid_manifest += 1; @@ -832,7 +878,7 @@ async fn prepare_active_conversation_store_for_issues( continue; } }; - if conversation_manifest_is_codex(&manifest) { + if recovered_harness_kind_from_manifest(&manifest) != OPENHANDS_AGENT_SERVER_KIND { continue; } if workspace.checkout_generation().is_some() @@ -1103,6 +1149,33 @@ pub(super) fn build_workspace_manager_config_with_retention( } } +/// A changed default must not strand an already submitted native OpenHands run. +/// ACP-only workspaces never need the native server or its credential configuration. +pub(super) async fn requires_openhands_recovery( + manager: &WorkspaceManager, +) -> Result { + for (workspace, _) in manager.list_all_workspaces().await? { + let Some(mut run) = manager.load_run_manifest(&workspace).await? else { + continue; + }; + let Some(conversation) = + recovered_conversation_manifest(manager, &workspace, Some(&mut run)).await? + else { + continue; + }; + if recovered_harness_kind_from_manifest(&conversation) == "openhands_agent_server" + && recoverable_run_manifest( + &run, + Some(&conversation), + workspace.checkout_generation().is_some(), + ) + { + return Ok(true); + } + } + Ok(false) +} + pub(super) async fn build_runtime_transport( runtime: &RunRuntimeConfig, prepared_tooling: Option, @@ -1169,7 +1242,7 @@ pub(super) async fn build_runtime_transport( Ok((transport, Some(supervisor))) } -fn runtime_checkout_credential_envs(runtime: &RunRuntimeConfig) -> BTreeSet { +pub(super) fn runtime_checkout_credential_envs(runtime: &RunRuntimeConfig) -> BTreeSet { runtime .repository_checkouts .as_ref() @@ -1203,7 +1276,9 @@ fn strict_openhands_cleanup_requires_conversation_store( manifest: &IssueConversationManifest, store: Option<&OpenHandsConversationStorePaths>, ) -> bool { - generation_bound && !conversation_manifest_is_codex(manifest) && store.is_none() + generation_bound + && recovered_harness_kind_from_manifest(manifest) == OPENHANDS_AGENT_SERVER_KIND + && store.is_none() } fn run_manifest_proves_no_harness_conversation(run: Option<&RunManifest>) -> bool { @@ -3948,11 +4023,17 @@ impl RuntimeWorkspaceBackend { terminal_cleanup_paths: HashSet::new(), recovered_run_started_at: BTreeMap::new(), codex_bin: env::var("OPENSYMPHONY_CODEX_BIN").unwrap_or_else(|_| "codex".into()), + acp_host: None, retain_failed, retry_state_root, } } + pub(super) fn with_acp_host(mut self, host: crate::opensymphony_acp::SessionHost) -> Self { + self.acp_host = Some(host); + self + } + pub(super) fn with_openhands_conversation_store( mut self, store: Option, @@ -4008,6 +4089,20 @@ impl RuntimeWorkspaceBackend { } return Ok(()); }; + // ACP process/lease and uncertain-submission fences precede both grant revocation + // and filesystem cleanup. A failed stop keeps the execution environment intact. + if let Some(raw) = self + .manager + .read_text_artifact(&handle, &handle.conversation_manifest_path()) + .await? + && serde_json::from_str::(&raw) + .ok() + .is_some_and(|value| value.get("acp").is_some_and(|v| !v.is_null())) + { + acp::retire(&self.manager, &handle, self.acp_host.as_ref()) + .await + .map_err(CliWorkspaceError::ConversationLifecycle)?; + } if let Some(scope_grants) = &self.scope_grants { if let Some(target) = cleanup_target { scope_grants.revoke_issue_generation(&target.identifier, &target.generation); @@ -4043,7 +4138,14 @@ impl RuntimeWorkspaceBackend { .read_text_artifact(&handle, &manifest_path) .await?; if let Some(raw_manifest) = raw_conversation_manifest { - match serde_json::from_str::(&raw_manifest) { + match acp::conversation_view(&raw_manifest) { + Ok(manifest) + if recovered_harness_kind_from_manifest(&manifest) == acp::KIND => + { + acp::retire(&self.manager, &handle, self.acp_host.as_ref()) + .await + .map_err(CliWorkspaceError::ConversationLifecycle)?; + } Ok(mut manifest) if conversation_manifest_is_codex(&manifest) => { let envelope_compatible = if handle.checkout_generation().is_some() { self.manager @@ -4314,7 +4416,7 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { datetime_to_timestamp_ms(run.started_at.unwrap_or(run.created_at)), ); } - let had_in_flight_run = run_manifest.as_ref().is_some_and(|run| { + let mut had_in_flight_run = run_manifest.as_ref().is_some_and(|run| { matches!( run.status, RunStatus::Preparing | RunStatus::Prepared | RunStatus::Running @@ -4323,6 +4425,13 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { let conversation_manifest = recovered_conversation_manifest(&self.manager, &handle, run_manifest.as_mut()) .await?; + had_in_flight_run |= conversation_manifest.as_ref().is_some_and(|m| { + m.transport_target.as_deref() == Some(acp::KIND) + && matches!( + m.last_execution_status.as_deref(), + Some("submitted" | "uncertain") + ) + }); let harness_kind = conversation_manifest .as_ref() .map(recovered_harness_kind_from_manifest); @@ -5126,7 +5235,7 @@ async fn recovered_conversation_manifest( ) -> Result, WorkspaceError> { let manifest_path = handle.conversation_manifest_path(); if let Some(raw_manifest) = manager.read_text_artifact(handle, &manifest_path).await? { - match serde_json::from_str::(&raw_manifest) { + match acp::conversation_view(&raw_manifest) { Ok(manifest) => { if let Some(run_manifest) = run_manifest { let pending_path = pending_conversation_manifest_path(handle); @@ -5331,6 +5440,17 @@ fn recoverable_run_manifest( conversation_manifest: Option<&IssueConversationManifest>, strict_checkout: bool, ) -> bool { + if conversation_manifest.is_some_and(|m| recovered_harness_kind_from_manifest(m) == acp::KIND) { + return matches!( + run_manifest.status, + RunStatus::Preparing | RunStatus::Prepared | RunStatus::Running + ) || conversation_manifest.is_some_and(|m| { + matches!( + m.last_execution_status.as_deref(), + Some("submitted" | "uncertain") + ) + }); + } let envelope_compatible = match run_manifest.runtime_envelope.as_ref() { Some(expected) => conversation_manifest .and_then(|manifest| manifest.runtime_envelope.as_ref()) @@ -5427,6 +5547,7 @@ fn conversation_metadata_from_manifest( manifest: &IssueConversationManifest, ) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: manifest.conversation_id.clone(), server_base_url: manifest.server_base_url.clone(), transport_target: manifest.transport_target.clone(), @@ -5454,12 +5575,29 @@ fn conversation_metadata_from_manifest( } impl RuntimeWorkerBackend { + #[cfg(test)] pub(super) fn new( client: OpenHandsClient, workflow: Arc, workspace_manager: Arc, memory_env: Option, worker_env: BTreeMap, + ) -> Self { + Self::new_with_client( + Some(client), + workflow, + workspace_manager, + memory_env, + worker_env, + ) + } + + pub(super) fn new_with_client( + client: Option, + workflow: Arc, + workspace_manager: Arc, + memory_env: Option, + worker_env: BTreeMap, ) -> Self { let (updates_tx, updates_rx) = mpsc::unbounded_channel(); let workpad_comment_source = match build_linear_client(&workflow) { @@ -5493,6 +5631,8 @@ impl RuntimeWorkerBackend { codex_bin: env::var("OPENSYMPHONY_CODEX_BIN").unwrap_or_else(|_| "codex".into()), codex_schema_validators: Arc::new(AsyncMutex::new(HashMap::new())), codex_interrupts: Arc::new(Mutex::new(HashMap::new())), + acp_host: None, + acp_active: Arc::new(Mutex::new(HashMap::new())), launch_timeout: DEFAULT_WORKER_LAUNCH_TIMEOUT, updates_tx, updates_rx, @@ -5514,6 +5654,11 @@ impl RuntimeWorkerBackend { self } + pub(super) fn with_acp_host(mut self, host: crate::opensymphony_acp::SessionHost) -> Self { + self.acp_host = Some(host); + self + } + pub(super) fn with_openhands_conversation_store( mut self, store: Option, @@ -5599,16 +5744,21 @@ impl RuntimeWorkerBackend { let finished_worker_id = worker_id.clone(); let (launch_tx, launch_rx) = oneshot::channel(); let run = request.run.clone(); - let route = request.route.clone(); + let mut route = request.route.clone(); + if !recovered { + bind_acp_profile_model(&mut route, &workflow); + } let recovered = recovered && matches!( route.harness_kind.as_str(), - OPENHANDS_AGENT_SERVER_KIND | CODEX_APP_SERVER_KIND + OPENHANDS_AGENT_SERVER_KIND | CODEX_APP_SERVER_KIND | acp::KIND ); let pending_route = route.clone(); let codex_bin = self.codex_bin.clone(); let codex_schema_validators = Arc::clone(&self.codex_schema_validators); let codex_interrupts = Arc::clone(&self.codex_interrupts); + let acp_host = self.acp_host.get_or_insert_with(acp::new_host).clone(); + let acp_active = self.acp_active.clone(); let launch_worker_id = worker_id.clone(); let handle = tokio::spawn(async move { let mut launch_tx = Some(launch_tx); @@ -5780,9 +5930,61 @@ impl RuntimeWorkerBackend { report_launch_failure(&mut launch_tx, error); return; } - let target_is_codex = route.harness_kind == CODEX_APP_SERVER_KIND; + let prior_acp = if recovered_conversation + .as_ref() + .is_some_and(|m| recovered_harness_kind_from_manifest(m) == acp::KIND) + { + match workspace_manager + .load_conversation_manifest(&ensured.handle) + .await + { + Ok(manifest) => manifest.and_then(|m| m.acp), + Err(error) => { + report_launch_failure(&mut launch_tx, error.to_string()); + return; + } + } + } else { + None + }; + let acp_model_changed = if prior_acp.is_some() { + match workspace_manager + .read_text_artifact( + &ensured.handle, + &ensured.handle.metadata_dir().join("harness-route.json"), + ) + .await + { + Ok(Some(raw)) => match serde_json::from_str::< + crate::opensymphony_orchestrator::HarnessRouteDecision, + >(&raw) + { + Ok(prior) => { + prior.model != route.model || prior.model_profile != route.model_profile + } + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("invalid persisted ACP route: {error}"), + ); + return; + } + }, + Ok(None) => false, + Err(error) => { + report_launch_failure(&mut launch_tx, error.to_string()); + return; + } + } + } else { + false + }; let switching_harness = recovered_conversation.as_ref().is_some_and(|manifest| { - conversation_manifest_is_codex(manifest) != target_is_codex + recovered_harness_kind_from_manifest(manifest) != route.harness_kind + || acp_model_changed + || prior_acp.as_ref().is_some_and(|state| { + Some(&state.identity.profile_id) != route.harness_profile.as_ref() + }) }); if let Some(error) = parent_harness_switch_error(parent_execution.is_some(), switching_harness) @@ -5790,7 +5992,7 @@ impl RuntimeWorkerBackend { report_launch_failure(&mut launch_tx, error); return; } - let superseded_harness_manifest = switching_harness.then(|| { + let mut superseded_harness_manifest = switching_harness.then(|| { recovered_conversation .as_ref() .expect("switching harness requires a prior conversation manifest") @@ -5962,7 +6164,7 @@ impl RuntimeWorkerBackend { .filter(|_| !switching_harness) { Some(manifest) => match recover_parent_memory_bearer( - &client, + client.as_ref().expect("OpenHands route has a client"), manifest, memory_env .as_ref() @@ -6032,126 +6234,156 @@ impl RuntimeWorkerBackend { }; let mut memory_grant_requires_fresh_conversation = false; let mut memory_grant_error = None; - let worker_memory_env = memory_env.as_ref().map(|memory| { - let mut scoped = memory.clone(); - scoped.project = worker_memory_project(&issue, &memory.project); - scoped.parent_scope = parent_runtime_envelope.is_some(); - scoped.execution_repo = if scoped.parent_scope { - String::new() - } else { - runtime_envelope - .as_ref() - .map(|envelope| envelope.repository_binding.repository.id.to_string()) - .unwrap_or_else(|| memory.execution_repo.clone()) - }; - scoped.run_id = runtime_envelope + // Reconciliation of a persisted outcome must not rotate grants or retire + // its conversation before reading the durable prompt result. + let reconcile_acp_outcome = recovered + && prior_acp.as_ref().is_some_and(|state| { + state.identity.run_id == run_id + && state.identity.attempt == run.attempt.map(|a| a.get()).unwrap_or(1) + && matches!( + state.status, + crate::opensymphony_workspace::AcpSessionStatus::Submitted + | crate::opensymphony_workspace::AcpSessionStatus::Uncertain + | crate::opensymphony_workspace::AcpSessionStatus::Finished + ) + }); + let worker_memory_env = + memory_env .as_ref() - .map(|envelope| envelope.run_id.clone()) - .or_else(|| { - parent_runtime_envelope + .filter(|_| !reconcile_acp_outcome) + .map(|memory| { + let mut scoped = memory.clone(); + scoped.project = worker_memory_project(&issue, &memory.project); + scoped.parent_scope = parent_runtime_envelope.is_some(); + scoped.execution_repo = if scoped.parent_scope { + String::new() + } else { + runtime_envelope + .as_ref() + .map(|envelope| { + envelope.repository_binding.repository.id.to_string() + }) + .unwrap_or_else(|| memory.execution_repo.clone()) + }; + scoped.run_id = runtime_envelope .as_ref() .map(|envelope| envelope.run_id.clone()) - }); - scoped.attempt = runtime_envelope - .as_ref() - .map(|envelope| envelope.attempt) - .or_else(|| { - parent_runtime_envelope + .or_else(|| { + parent_runtime_envelope + .as_ref() + .map(|envelope| envelope.run_id.clone()) + }) + .or_else(|| memory.run_id.clone()); + scoped.attempt = runtime_envelope .as_ref() .map(|envelope| envelope.attempt) - }); - scoped.target_commit = runtime_envelope - .as_ref() - .map(|envelope| envelope.target_commit.clone()); - scoped.checkout_head = initially_verified_checkout - .as_ref() - .map(|checkout| checkout.head.clone()); - let authorized_repositories = parent_runtime_envelope - .as_ref() - .map(parent_authorized_repositories) - .unwrap_or_else(|| { - scoped - .authorized_repositories_by_project - .get(&scoped.project) - .cloned() .or_else(|| { - scoped - .authorized_repositories_by_project - .iter() - .find(|(project, _)| { - project.eq_ignore_ascii_case(&scoped.project) - }) - .map(|(_, repositories)| repositories.clone()) + parent_runtime_envelope + .as_ref() + .map(|envelope| envelope.attempt) }) - .filter(|repositories| !repositories.is_empty()) - .unwrap_or_else(|| BTreeSet::from([scoped.execution_repo.clone()])) - }); - scoped.authorized_repositories = authorized_repositories.clone(); - if let Some(grants) = &scoped.scope_grants { - let mut authorized_work_items = parent_authorized_work_items.clone(); - authorized_work_items.extend( - parent_execution + .or(memory.attempt); + scoped.target_commit = runtime_envelope .as_ref() - .into_iter() - .flat_map(|parent| parent.child_checkout_map.repositories.values()) - .flat_map(|checkout| checkout.retained_checkouts.iter()) - .flat_map(|checkout| { - [checkout.issue_id.clone(), checkout.identifier.clone()] - }), - ); - let live_overlays = parent_runtime_envelope - .as_ref() - .map(parent_live_overlays) - .unwrap_or_default(); - let grant = MemoryScopeGrant { - project: scoped.project.clone(), - project_set: scoped.project_set.clone(), - execution_repo: scoped.execution_repo.clone(), - authorized_repositories, - authorized_work_items, - live_overlays, - issue: issue.identifier.to_string(), - run_id: scoped.run_id.clone(), - attempt: scoped.attempt, - checkout_generation: runtime_envelope + .map(|envelope| envelope.target_commit.clone()); + scoped.checkout_head = initially_verified_checkout .as_ref() - .map(|envelope| envelope.checkout_generation.clone()) - .or_else(|| { - parent_execution.as_ref().map(|parent| { - format!("parent:{}", parent.manifest.hierarchy_generation) - }) - }), - target_commit: scoped.target_commit.clone(), - checkout_head: scoped.checkout_head.clone(), - visibility: scoped.visibility, - capabilities: BTreeSet::new(), - }; - let token = if scoped.parent_scope { - if let Some(bearer) = recovered_parent_memory_bearer.as_deref() { - match grants.restore_parent_claims(bearer, grant) { - Ok(token) => token, - Err(error) => { - memory_grant_error = Some(error); - String::new() + .map(|checkout| checkout.head.clone()); + let authorized_repositories = parent_runtime_envelope + .as_ref() + .map(parent_authorized_repositories) + .unwrap_or_else(|| { + scoped + .authorized_repositories_by_project + .get(&scoped.project) + .cloned() + .or_else(|| { + scoped + .authorized_repositories_by_project + .iter() + .find(|(project, _)| { + project.eq_ignore_ascii_case(&scoped.project) + }) + .map(|(_, repositories)| repositories.clone()) + }) + .filter(|repositories| !repositories.is_empty()) + .unwrap_or_else(|| { + BTreeSet::from([scoped.execution_repo.clone()]) + }) + }); + scoped.authorized_repositories = authorized_repositories.clone(); + if let Some(grants) = &scoped.scope_grants { + let mut authorized_work_items = parent_authorized_work_items.clone(); + authorized_work_items.extend( + parent_execution + .as_ref() + .into_iter() + .flat_map(|parent| { + parent.child_checkout_map.repositories.values() + }) + .flat_map(|checkout| checkout.retained_checkouts.iter()) + .flat_map(|checkout| { + [checkout.issue_id.clone(), checkout.identifier.clone()] + }), + ); + let live_overlays = parent_runtime_envelope + .as_ref() + .map(parent_live_overlays) + .unwrap_or_default(); + let grant = MemoryScopeGrant { + project: scoped.project.clone(), + project_set: scoped.project_set.clone(), + execution_repo: scoped.execution_repo.clone(), + authorized_repositories, + authorized_work_items, + live_overlays, + issue: issue.identifier.to_string(), + run_id: scoped.run_id.clone(), + attempt: scoped.attempt, + checkout_generation: runtime_envelope + .as_ref() + .map(|envelope| envelope.checkout_generation.clone()) + .or_else(|| { + parent_execution.as_ref().map(|parent| { + format!( + "parent:{}", + parent.manifest.hierarchy_generation + ) + }) + }), + target_commit: scoped.target_commit.clone(), + checkout_head: scoped.checkout_head.clone(), + visibility: scoped.visibility, + capabilities: BTreeSet::new(), + }; + let token = if scoped.parent_scope { + if let Some(bearer) = recovered_parent_memory_bearer.as_deref() { + match grants.restore_parent_claims(bearer, grant) { + Ok(token) => token, + Err(error) => { + memory_grant_error = Some(error); + String::new() + } + } + } else { + let (token, requires_fresh_conversation) = + grants.issue_or_refresh_parent_claims(grant); + memory_grant_requires_fresh_conversation = + requires_fresh_conversation; + token } - } - } else { - let (token, requires_fresh_conversation) = - grants.issue_or_refresh_parent_claims(grant); - memory_grant_requires_fresh_conversation = requires_fresh_conversation; - token + } else { + let (token, requires_fresh_conversation) = + grants.issue_or_refresh_with_claims(grant); + memory_grant_requires_fresh_conversation = + requires_fresh_conversation; + token + }; + scoped.token = Some(token.clone()); } - } else { - let (token, requires_fresh_conversation) = - grants.issue_or_refresh_with_claims(grant); - memory_grant_requires_fresh_conversation = requires_fresh_conversation; - token - }; - scoped.token = Some(token.clone()); - } - scoped.authorized_repositories_by_project.clear(); - scoped - }); + scoped.authorized_repositories_by_project.clear(); + scoped + }); if let Some(error) = memory_grant_error { report_launch_failure( &mut launch_tx, @@ -6165,29 +6397,139 @@ impl RuntimeWorkerBackend { memory.scope_grants.is_some() && !memory.parent_scope })); let mut worker_environment = worker_env.clone(); + if route.harness_kind == acp::KIND { + // Only the run-scoped memory grant may populate reserved ACP + // variables; workflow/daemon values can carry an admin bearer. + worker_environment.retain(|name, _| { + !crate::opensymphony_acp::is_reserved_memory_environment_name(name) + }); + } if let Some(memory) = &worker_memory_env { inject_memory_env(&mut worker_environment, memory); } - let mut runner = IssueSessionRunner::with_environment( - client.clone(), - runner_config - .clone() - .with_memory(worker_memory_env.as_ref().map(|memory| { - memory_access_from_runtime( - memory, - (recovered - || memory_grant_registry_recovered - || memory_grant_requires_fresh_conversation) - && memory.scope_grants.is_some(), + let acp_identity_changed = if !route.dry_run + && route.harness_kind == acp::KIND + && !switching_harness + && !memory_grant_requires_fresh_conversation + && !reconcile_acp_outcome + { + if let Some(previous) = prior_acp.as_ref() { + let environment = acp::launch_environment(env::vars_os(), &worker_environment); + match acp::effective_launch_identity( + &route, + &workflow, + &worker_environment, + &environment, + &acp::production_client_limits(), + ) { + Ok(identity) => { + previous.identity.profile_fingerprint != identity.profile_fingerprint + || previous.identity.credential_scope != identity.credential_scope + } + Err(error) => { + report_launch_failure(&mut launch_tx, error); + return; + } + } + } else { + false + } + } else { + false + }; + if !route.dry_run && parent_execution.is_some() && prior_acp.is_some() { + if expected_parent_conversation_id.is_none() { + report_launch_failure( + &mut launch_tx, + "parent ACP continuation is missing its authoritative conversation binding", + ); + return; + } + if memory_grant_requires_fresh_conversation { + report_launch_failure( + &mut launch_tx, + "parent ACP memory authorization requires a fresh conversation; refusing to replace its authoritative session", + ); + return; + } + } + if !route.dry_run + && prior_acp.is_some() + && (switching_harness + || memory_grant_requires_fresh_conversation + || acp_identity_changed) + { + if let Err(error) = acp::retire_and_archive( + &workspace_manager, + &ensured.handle, + &acp_host, + parent_execution.is_some(), + ) + .await + { + report_launch_failure(&mut launch_tx, error); + return; + } + superseded_harness_manifest = None; + } + if !route.dry_run + && route.harness_kind == acp::KIND + && let Some(previous) = superseded_harness_manifest.as_ref() + { + let result = async { + persist_superseded_harness_manifest( + &workspace_manager, + &ensured.handle, + previous, + ) + .await?; + retire_superseded_harness_session( + &ensured.handle, + previous, + &route.harness_kind, + openhands_conversation_store.as_ref(), + &codex_bin, + &checkout_credential_envs, + ) + .await?; + let path = workspace_manager + .validate_workspace_owned_path( + &ensured.handle, + &ensured.handle.conversation_manifest_path(), ) - })), - OverlayEnvironment { - overrides: worker_environment.clone(), - blocked: checkout_credential_envs.clone(), - }, - ); + .await + .map_err(|e| e.to_string())?; + fs::remove_file(path).await.map_err(|e| e.to_string()) + } + .await; + if let Err(error) = result { + report_launch_failure(&mut launch_tx, error); + return; + } + superseded_harness_manifest = None; + } + let mut runner = client.clone().map(|client| { + IssueSessionRunner::with_environment( + client, + runner_config + .clone() + .with_memory(worker_memory_env.as_ref().map(|memory| { + memory_access_from_runtime( + memory, + (recovered + || memory_grant_registry_recovered + || memory_grant_requires_fresh_conversation) + && memory.scope_grants.is_some(), + ) + })), + OverlayEnvironment { + overrides: worker_environment.clone(), + blocked: checkout_credential_envs.clone(), + }, + ) + }); if let Some(source) = workpad_comment_source.clone() { - runner = runner.with_workpad_comment_source(source); + runner = runner.map(|runner| runner.with_workpad_comment_source(source)); } let repository_instructions = if ensured.handle.checkout_generation().is_some() { let result = if let Some(checkout) = initially_verified_checkout.as_ref() { @@ -6205,7 +6547,9 @@ impl RuntimeWorkerBackend { }; match result { Ok(instructions) => { - runner = runner.with_repository_instructions(instructions.clone()); + runner = runner.map(|runner| { + runner.with_repository_instructions(instructions.clone()) + }); instructions } Err(error) => { @@ -6288,7 +6632,8 @@ impl RuntimeWorkerBackend { .with_normal_retry_count(run.normal_retry_count) .with_repository_binding(run.repository_binding.clone()) .with_runtime_envelope(runtime_envelope.clone()) - .with_parent_runtime_envelope(parent_runtime_envelope.clone()); + .with_parent_runtime_envelope(parent_runtime_envelope.clone()) + .with_acp_route((route.harness_kind == acp::KIND).then(|| acp_run_route(&route))); let mut initialize_fresh_conversation = false; let mut run_manifest = if recovered { match workspace_manager.load_run_manifest(&ensured.handle).await { @@ -6296,7 +6641,8 @@ impl RuntimeWorkerBackend { let conversation_manifest = if matches!( run_manifest.status, RunStatus::Prepared | RunStatus::Running - ) { + ) || route.harness_kind == acp::KIND + { if route.harness_kind == CODEX_APP_SERVER_KIND { match load_codex_conversation_manifest( &workspace_manager, @@ -6336,20 +6682,32 @@ impl RuntimeWorkerBackend { }; initialize_fresh_conversation = conversation_manifest.as_ref().is_some_and(|manifest| { - route.harness_kind != CODEX_APP_SERVER_KIND + route.harness_kind == OPENHANDS_AGENT_SERVER_KIND && fresh_conversation_initialization_pending( &run_manifest, manifest, ) }); initialize_fresh_conversation |= route.harness_kind - != CODEX_APP_SERVER_KIND + == OPENHANDS_AGENT_SERVER_KIND && memory_grant_requires_fresh_conversation; - if recoverable_run_manifest( - &run_manifest, - conversation_manifest.as_ref(), - ensured.handle.checkout_generation().is_some(), - ) { + if (route.harness_kind == acp::KIND + && run_manifest.acp_route.as_ref() == Some(&acp_run_route(&route)) + && matches!( + run_manifest.status, + RunStatus::Preparing | RunStatus::Prepared | RunStatus::Running + )) + || (acp_identity_changed + && matches!( + run_manifest.status, + RunStatus::Preparing | RunStatus::Prepared | RunStatus::Running + )) + || recoverable_run_manifest( + &run_manifest, + conversation_manifest.as_ref(), + ensured.handle.checkout_generation().is_some(), + ) + { run_manifest } else { report_launch_failure( @@ -6394,6 +6752,7 @@ impl RuntimeWorkerBackend { }; if recovered + && route.harness_kind != acp::KIND && runtime_envelope.as_ref().is_some_and(|expected| { run_manifest.runtime_envelope.as_ref() != Some(expected) }) @@ -6579,8 +6938,9 @@ impl RuntimeWorkerBackend { .as_ref() .map(compose_parent_continuation_prompt) }); - runner = runner.with_terminal_prompt(terminal_prompt.clone()); - runner = runner.with_continuation_prompt(continuation_prompt.clone()); + runner = runner.map(|runner| runner.with_terminal_prompt(terminal_prompt.clone())); + runner = + runner.map(|runner| runner.with_continuation_prompt(continuation_prompt.clone())); if route.dry_run { if let Some(sender) = launch_tx.take() { @@ -6633,12 +6993,13 @@ impl RuntimeWorkerBackend { return; } - if route.harness_kind == "codex_app_server" { + if route.harness_kind == acp::KIND { let fresh_conversation_grants = worker_memory_env .as_ref() - .and_then(|memory| memory.scope_grants.clone()) + .and_then(|memory| memory.scope_grants.as_ref()) .filter(|_| memory_grant_requires_fresh_conversation); - let mut outcome = run_codex_stdio_issue_with_mode( + // Keep the ACP future off the shared task stack for every route. + let mut outcome = Box::pin(acp::run_issue( &route, &workspace_manager, &ensured.handle, @@ -6648,18 +7009,15 @@ impl RuntimeWorkerBackend { &workflow, terminal_prompt.as_deref(), continuation_prompt.as_deref(), - &codex_bin, - &codex_schema_validators, - &codex_interrupts, + &acp_host, + &acp_active, &updates_tx, &mut launch_tx, - &worker_environment, - &checkout_credential_envs, + worker_environment, + checkout_credential_envs, recovered, - memory_grant_requires_fresh_conversation, fresh_conversation_grants, - issue.identifier.as_str(), - ) + )) .await; attach_parent_verification_receipt( &mut outcome, @@ -6670,56 +7028,108 @@ impl RuntimeWorkerBackend { parent_repair.as_ref(), ) .await; - if let Some(previous) = superseded_harness_manifest.as_ref() - && let Err(error) = retire_replaced_harness_session_if_durable( - &workspace_manager, - &ensured.handle, - previous, - target_is_codex, - runtime_envelope.as_ref(), - openhands_conversation_store.as_ref(), - &codex_bin, - &checkout_credential_envs, - ) - .await - { - tracing::warn!(%error, "failed to retire replaced harness session after replacement"); - } let _ = updates_tx.send(WorkerUpdate::Finished { worker_id: finished_worker_id.clone(), outcome, }); return; } - - let mut observer = SchedulerObserver { - worker_id: observer_worker_id.to_string(), - launch_tx, - updates_tx: updates_tx.clone(), - }; - let result = if recovered && !initialize_fresh_conversation { - runner - .recover_with_observer( - &workspace_manager, - &ensured.handle, - &mut run_manifest, - &issue, - &run, - &mut observer, - ) - .await - } else { - runner - .run_with_observer( + if !matches!( + route.harness_kind.as_str(), + OPENHANDS_AGENT_SERVER_KIND | CODEX_APP_SERVER_KIND + ) { + report_launch_failure(&mut launch_tx, "unsupported execution adapter"); + return; + } + if route.harness_kind == "codex_app_server" { + let fresh_conversation_grants = worker_memory_env + .as_ref() + .and_then(|memory| memory.scope_grants.clone()) + .filter(|_| memory_grant_requires_fresh_conversation); + let mut outcome = Box::pin(run_codex_stdio_issue_with_mode( + &route, + &workspace_manager, + &ensured.handle, + &mut run_manifest, + &issue, + &run, + &workflow, + terminal_prompt.as_deref(), + continuation_prompt.as_deref(), + &codex_bin, + &codex_schema_validators, + &codex_interrupts, + &updates_tx, + &mut launch_tx, + &worker_environment, + &checkout_credential_envs, + recovered, + memory_grant_requires_fresh_conversation, + fresh_conversation_grants, + issue.identifier.as_str(), + )) + .await; + attach_parent_verification_receipt( + &mut outcome, + &workspace_manager, + &ensured.handle, + &issue, + parent_runtime_envelope.as_ref(), + parent_repair.as_ref(), + ) + .await; + if let Some(previous) = superseded_harness_manifest.as_ref() + && let Err(error) = retire_replaced_harness_session_if_durable( &workspace_manager, &ensured.handle, - &mut run_manifest, - &issue, - &run, - &workflow, - &mut observer, + previous, + &route.harness_kind, + runtime_envelope.as_ref(), + openhands_conversation_store.as_ref(), + &codex_bin, + &checkout_credential_envs, ) .await + { + tracing::warn!(%error, "failed to retire replaced harness session after replacement"); + } + let _ = updates_tx.send(WorkerUpdate::Finished { + worker_id: finished_worker_id.clone(), + outcome, + }); + return; + } + + let Some(runner) = runner else { + report_launch_failure(&mut launch_tx, "OpenHands execution client unavailable"); + return; + }; + let mut observer = SchedulerObserver { + worker_id: observer_worker_id.to_string(), + launch_tx, + updates_tx: updates_tx.clone(), + }; + let result = if recovered && !initialize_fresh_conversation { + Box::pin(runner.recover_with_observer( + &workspace_manager, + &ensured.handle, + &mut run_manifest, + &issue, + &run, + &mut observer, + )) + .await + } else { + Box::pin(runner.run_with_observer( + &workspace_manager, + &ensured.handle, + &mut run_manifest, + &issue, + &run, + &workflow, + &mut observer, + )) + .await }; let launch_succeeded = observer.launch_tx.is_none(); @@ -6764,7 +7174,7 @@ impl RuntimeWorkerBackend { &workspace_manager, &ensured.handle, previous, - target_is_codex, + &route.harness_kind, runtime_envelope.as_ref(), openhands_conversation_store.as_ref(), &codex_bin, @@ -6994,6 +7404,7 @@ fn route_decision_payload( serde_json::json!({ "task_type": &route.task_type, "harness_kind": &route.harness_kind, + "harness_profile": &route.harness_profile, "model": &route.model, "model_profile": &route.model_profile, "reason": &route.reason, @@ -7012,6 +7423,7 @@ fn dry_run_conversation_metadata( route: &crate::opensymphony_orchestrator::HarnessRouteDecision, ) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: ConversationId::new(format!( "{ROUTE_PREVIEW_CONVERSATION_PREFIX}{}", run.worker_id @@ -7153,10 +7565,20 @@ fn memory_scope_prompt_values(project: &str, repo: &str) -> String { } fn memory_scope_prompt_from_environment(environment: &BTreeMap) -> Option { - let mut prompt = memory_scope_prompt_values( - environment.get("OPENSYMPHONY_MEMORY_PROJECT")?, - environment.get("OPENSYMPHONY_MEMORY_EXECUTION_REPO")?, - ); + // The resolved worker overlay is authoritative. A parent intentionally has + // no execution repository and must ask for one on each code-memory call. + let project = environment.get("OPENSYMPHONY_MEMORY_PROJECT")?; + let execution_repo = environment.get("OPENSYMPHONY_MEMORY_EXECUTION_REPO"); + if execution_repo.is_none() { + environment.get("OPENSYMPHONY_MEMORY_ENDPOINT")?; + } + let mut prompt = if let Some(repo) = execution_repo { + memory_scope_prompt_values(project, repo) + } else { + format!( + "\nMemory scope is repository-neutral for project {project} and requires an explicit repository for code access. Pass this project and a named authorized repository as `project` and `repo` arguments to memory.context, memory.search, memory.related, and code.ast.* calls; do not use process-global scope." + ) + }; if let Some(project_set) = environment.get("OPENSYMPHONY_MEMORY_PROJECT_SET") { prompt.push_str(&format!(" Project set is {project_set}.")); } @@ -7168,16 +7590,21 @@ fn memory_scope_prompt_from_environment(environment: &BTreeMap) )); } if let Some(run_id) = environment.get("OPENSYMPHONY_MEMORY_RUN_ID") { - prompt.push_str(&format!( - " Run identity is {run_id}; pass this exact value as runId only for a live execution-repository overlay." - )); + prompt.push_str(&format!(" Run identity is {run_id}.")); + if execution_repo.is_some() { + prompt.push_str( + " Pass this exact value as runId only for a live execution-repository overlay.", + ); + } } if let Some(attempt) = environment.get("OPENSYMPHONY_MEMORY_ATTEMPT") { prompt.push_str(&format!(" Attempt is {attempt}.")); } - prompt.push_str( - " Sibling repositories are persisted-memory and target-snapshot reads only; live workspace overlays are limited to this execution repository and verified run.", - ); + if execution_repo.is_some() { + prompt.push_str(" Sibling repositories are persisted-memory and target-snapshot reads only; live workspace overlays are limited to this execution repository and verified run."); + } else { + prompt.push_str(" Repository reads use persisted memory and target snapshots; parent scope has no live execution-repository overlay."); + } Some(prompt) } @@ -9368,12 +9795,12 @@ async fn load_codex_conversation_manifest( async fn retire_superseded_harness_session( workspace: &WorkspaceHandle, manifest: &IssueConversationManifest, - target_is_codex: bool, + target_harness: &str, openhands_conversation_store: Option<&OpenHandsConversationStorePaths>, codex_bin: &str, checkout_credential_envs: &BTreeSet, ) -> Result<(), String> { - if target_is_codex { + if recovered_harness_kind_from_manifest(manifest) == OPENHANDS_AGENT_SERVER_KIND { let store = openhands_conversation_store.ok_or_else(|| { "OpenHands conversation store is unavailable while switching to Codex".to_owned() })?; @@ -9391,9 +9818,13 @@ async fn retire_superseded_harness_session( manifest.conversation_id )), } - } else { + } else if conversation_manifest_is_codex(manifest) { archive_superseded_codex_thread(workspace, manifest, codex_bin, checkout_credential_envs) .await + } else { + Err(format!( + "cannot retire this adapter while switching to {target_harness}" + )) } } @@ -9402,7 +9833,7 @@ async fn retire_replaced_harness_session_if_durable( workspace_manager: &WorkspaceManager, workspace: &WorkspaceHandle, previous: &IssueConversationManifest, - target_is_codex: bool, + target_harness: &str, expected_envelope: Option<&TerminalRuntimeEnvelope>, openhands_conversation_store: Option<&OpenHandsConversationStorePaths>, codex_bin: &str, @@ -9424,7 +9855,7 @@ async fn retire_replaced_harness_session_if_durable( let Some(replacement_envelope) = replacement.runtime_envelope.as_ref() else { return Ok(()); }; - if conversation_manifest_is_codex(&replacement) != target_is_codex + if recovered_harness_kind_from_manifest(&replacement) != target_harness || !runtime_envelopes_match_except_binding(expected_envelope, replacement_envelope) || replacement_envelope.conversation_binding.as_deref() != Some(replacement.conversation_id.as_str()) @@ -9434,7 +9865,7 @@ async fn retire_replaced_harness_session_if_durable( retire_superseded_harness_session( workspace, previous, - target_is_codex, + target_harness, openhands_conversation_store, codex_bin, checkout_credential_envs, @@ -9536,6 +9967,7 @@ fn codex_conversation_metadata( route: &crate::opensymphony_orchestrator::HarnessRouteDecision, ) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: ConversationId::new(conversation_id) .expect("Codex conversation id should not be empty"), server_base_url: None, @@ -9724,8 +10156,90 @@ impl WorkerBackend for RuntimeWorkerBackend { async fn recover_worker( &mut self, - request: WorkerStartRequest, + mut request: WorkerStartRequest, ) -> Result { + if request.route.harness_kind == acp::KIND { + let recovery_path = fs::canonicalize(&request.workspace.path) + .await + .map_err(|e| CliWorkerError::LaunchFailed(e.to_string()))?; + let handle = self + .workspace_manager + .list_all_workspaces() + .await + .map_err(|e| CliWorkerError::LaunchFailed(e.to_string()))? + .into_iter() + .find(|(h, _)| h.workspace_path() == recovery_path) + .map(|(h, _)| h) + .ok_or_else(|| { + CliWorkerError::LaunchFailed("ACP recovery workspace missing".into()) + })?; + let run = self + .workspace_manager + .load_run_manifest(&handle) + .await + .map_err(|e| CliWorkerError::LaunchFailed(e.to_string()))? + .ok_or_else(|| CliWorkerError::LaunchFailed("ACP recovery run missing".into()))?; + let expected_run_id = format!("run-{}", request.run.worker_id); + if run.run_id != expected_run_id + || request + .run + .attempt + .is_some_and(|attempt| run.attempt != attempt.get()) + { + return Err(CliWorkerError::LaunchFailed( + "ACP recovery run identity mismatch".into(), + )); + } + if let Some(bound) = run.acp_route { + if bound.harness_kind != acp::KIND || bound.harness_profile.is_none() { + return Err(CliWorkerError::LaunchFailed( + "ACP recovery route identity mismatch".into(), + )); + } + request.route = harness_route_from_acp_run_route(bound); + } else { + // Legacy manifests have only a workspace-wide route. It belongs to + // this run only if the durable ACP identity names the same run. + let recorded = self + .workspace_manager + .load_conversation_manifest(&handle) + .await + .map_err(|e| CliWorkerError::LaunchFailed(e.to_string()))? + .and_then(|manifest| manifest.acp) + .ok_or_else(|| { + CliWorkerError::LaunchFailed("ACP recovery identity missing".into()) + })?; + if recorded.identity.run_id != run.run_id + || recorded.identity.attempt != run.attempt + { + return Err(CliWorkerError::LaunchFailed( + "ACP recovery has no route bound to the prepared run".into(), + )); + } + let raw = self + .workspace_manager + .read_text_artifact(&handle, &handle.metadata_dir().join("harness-route.json")) + .await + .map_err(|e| CliWorkerError::LaunchFailed(e.to_string()))?; + if let Some(raw) = raw { + let persisted: crate::opensymphony_orchestrator::HarnessRouteDecision = + serde_json::from_str(&raw) + .map_err(|e| CliWorkerError::LaunchFailed(e.to_string()))?; + if persisted.harness_kind != acp::KIND + || persisted.harness_profile.as_ref() != Some(&recorded.identity.profile_id) + { + return Err(CliWorkerError::LaunchFailed( + "ACP recovery route identity mismatch".into(), + )); + } + request.route = persisted; + } else { + request.route.harness_profile = Some(recorded.identity.profile_id); + request.route.model = None; + request.route.model_profile = None; + } + } + } let pending = self.spawn_worker_task(request, true); let worker_id = pending.worker_id.clone(); let route = pending.route.clone(); @@ -9760,16 +10274,49 @@ impl WorkerBackend for RuntimeWorkerBackend { continue; }; if let Err(error) = task.handle.await { + let acp_session = self + .acp_active + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&worker_id); + let (kind, harness_stopped) = if let Some(session) = acp_session { + session.cancellation.cancel(); + let stopped = timeout(Duration::from_secs(15), async { + loop { + match acp::observe_stop(&session, &self.workspace_manager).await { + Ok(acp::StopObservation::Stopped(_)) => return true, + Ok(acp::StopObservation::Pending) => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + Ok(acp::StopObservation::Uncertain) | Err(_) => return false, + } + } + }) + .await + .unwrap_or(false); + ( + if stopped { + WorkerOutcomeKind::Failed + } else { + WorkerOutcomeKind::Detached + }, + stopped, + ) + } else { + (WorkerOutcomeKind::Failed, false) + }; + let mut outcome = WorkerOutcomeRecord::from_run( + &task.run, + kind, + now_timestamp(), + Some("worker task terminated unexpectedly".to_string()), + Some(error.to_string()), + ); + outcome.harness_stopped = harness_stopped; updates.push(WorkerUpdate::Finished { worker_id: crate::opensymphony_domain::WorkerId::new(worker_id) .expect("worker id should remain valid"), - outcome: WorkerOutcomeRecord::from_run( - &task.run, - WorkerOutcomeKind::Failed, - now_timestamp(), - Some("worker task terminated unexpectedly".to_string()), - Some(error.to_string()), - ), + outcome, }); } } @@ -9782,6 +10329,69 @@ impl WorkerBackend for RuntimeWorkerBackend { worker_id: &crate::opensymphony_domain::WorkerId, reason: WorkerAbortReason, ) -> Result<(), Self::Error> { + let acp_session = self + .acp_active + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(worker_id.as_str()) + .cloned(); + if let Some(session) = acp_session { + session.cancellation.cancel(); + timeout(Duration::from_secs(15), async { + loop { + match acp::observe_stop(&session, &self.workspace_manager).await? { + acp::StopObservation::Stopped(_) => return Ok::<_, CliWorkerError>(()), + acp::StopObservation::Uncertain => { + return Err(CliWorkerError::InterruptFailed( + "ACP abort remains uncertain".into(), + )); + } + acp::StopObservation::Pending => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + } + }) + .await + .map_err(|_| CliWorkerError::InterruptFailed("ACP abort deadline exceeded".into()))??; + // Let the worker persist the stopped run and execute after_run before + // revoking grants or releasing its workspace to scheduler cleanup. + if let Some(task) = self.tasks.get_mut(worker_id.as_str()) { + let completion = timeout( + self.workspace_manager.config().hooks.timeout + Duration::from_secs(5), + &mut task.handle, + ) + .await + .map_err(|_| { + CliWorkerError::InterruptFailed( + "ACP workspace finalization deadline exceeded".into(), + ) + })?; + if let Err(error) = completion { + // The join result has been consumed. Remove the handle before + // poll_updates can observe it again, and report the failure + // through the scheduler's normal worker message boundary. + if let Some(task) = self.take_tracked_task(worker_id.as_str()) { + self.acp_active + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(worker_id.as_str()); + let _ = self.updates_tx.send(WorkerUpdate::Finished { + worker_id: worker_id.clone(), + outcome: WorkerOutcomeRecord::from_run( + &task.run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some("ACP worker task failed during abort finalization".into()), + Some(error.to_string()), + ) + .with_harness_stopped(), + }); + } + return Err(error.into()); + } + } + } let issue_identifier = self .worker_issue_ids .remove(worker_id.as_str()) @@ -9816,6 +10426,9 @@ impl WorkerBackend for RuntimeWorkerBackend { &mut self, command: crate::opensymphony_domain::HarnessInterruptCommand, ) -> Result { + if command.harness_kind == acp::KIND { + return acp::interrupt(&self.acp_active, &self.workspace_manager, &command).await; + } if command.harness_kind == CODEX_APP_SERVER_KIND { return send_codex_stdio_interrupt(&self.codex_interrupts, &command).await; } @@ -9827,7 +10440,9 @@ impl WorkerBackend for RuntimeWorkerBackend { } let mut runner = IssueSessionRunner::with_environment( - self.client.clone(), + self.client.clone().ok_or_else(|| { + CliWorkerError::InterruptFailed("OpenHands execution client unavailable".into()) + })?, self.runner_config.clone(), OverlayEnvironment { overrides: self.worker_env.clone(), @@ -10782,6 +11397,7 @@ mod tests { repository_binding: None, runtime_envelope: None, parent_runtime_envelope: None, + acp_route: None, attempt: 1, normal_retry_count: 0, pending_retry: false, @@ -10859,6 +11475,7 @@ mod tests { repository_binding: None, runtime_envelope: Some(runtime_envelope.clone()), parent_runtime_envelope: None, + acp_route: None, attempt: 1, normal_retry_count: 0, pending_retry: false, @@ -10944,6 +11561,7 @@ mod tests { repository_binding: None, runtime_envelope: Some(runtime_envelope.clone()), parent_runtime_envelope: None, + acp_route: None, attempt: 1, normal_retry_count: 0, pending_retry: false, @@ -13901,6 +14519,60 @@ mod tests { assert!(continuation_scope.contains("repo=/tmp/project-alpha/services/api")); } + #[test] + fn acp_parent_memory_guidance_uses_resolved_repository_neutral_scope() { + let memory = RuntimeMemoryEnv { + endpoint: "http://127.0.0.1:8765/mcp".into(), + token: None, + project: "project-parent".into(), + execution_repo: "stale-inherited-repo".into(), + parent_scope: true, + authorized_repositories: BTreeSet::from(["repo-one".into(), "repo-two".into()]), + authorized_repositories_by_project: BTreeMap::new(), + scope_grants: None, + project_set: Some("parent-set".into()), + visibility: crate::opensymphony_memory::MemoryVisibility::Private, + run_id: Some("parent-run-2".into()), + attempt: Some(2), + target_commit: None, + checkout_head: None, + }; + let mut worker_env = BTreeMap::from([( + "OPENSYMPHONY_MEMORY_EXECUTION_REPO".into(), + "ambient-repo".into(), + )]); + inject_memory_env(&mut worker_env, &memory); + assert!(!worker_env.contains_key("OPENSYMPHONY_MEMORY_EXECUTION_REPO")); + let prompt = memory_scope_prompt_from_environment(&worker_env).expect("parent guidance"); + for expected in [ + "repository-neutral", + "project-parent", + "repo-one,repo-two", + "parent-run-2", + "Attempt is 2", + "parent-set", + ] { + assert!(prompt.contains(expected), "missing {expected}: {prompt}"); + } + assert!(!prompt.contains("ambient-repo")); + assert!(!prompt.contains("stale-inherited-repo")); + assert!(prompt.contains("parent scope has no live execution-repository overlay")); + } + + #[test] + fn acp_worker_memory_overlay_discards_unmanaged_reserved_values() { + let mut worker_environment: BTreeMap = BTreeMap::from([ + ("OPENSYMPHONY_MEMORY_ADMIN_TOKEN".into(), "admin".into()), + ("OPENSYMPHONY_MEMORY_TOKEN".into(), "stale".into()), + ("OPENSYMPHONY_MEMORY_PROJECT".into(), "stale-project".into()), + ("AGENT_TOKEN".into(), "worker-grant".into()), + ]); + worker_environment + .retain(|name, _| !crate::opensymphony_acp::is_reserved_memory_environment_name(name)); + assert_eq!(worker_environment.len(), 1); + assert_eq!(worker_environment["AGENT_TOKEN"], "worker-grant"); + } + #[test] fn worker_memory_scope_prefers_canonical_project_id_over_slug() { let mut issue = sample_issue(); @@ -13967,6 +14639,7 @@ mod tests { route: crate::opensymphony_orchestrator::HarnessRouteDecision { task_type: "issue_execution".into(), harness_kind: "openhands_agent_server".into(), + harness_profile: None, model: None, model_profile: None, reason: "test default route".into(), @@ -14076,6 +14749,98 @@ mod tests { assert_eq!(recovered.workspace.path, ensured.handle.workspace_path()); } + #[tokio::test] + async fn openhands_startup_recovers_only_bound_pending_ownership() { + for valid in [true, false] { + let temp = TempDir::new().expect("temp"); + let root = temp.path().join("workspaces"); + let workflow = sample_workflow(temp.path(), &root); + let manager = WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager"); + let issue = sample_issue(); + let workspace = manager + .ensure(&issue_descriptor(&issue)) + .await + .expect("workspace") + .handle; + let mut run = manager + .start_run(&workspace, &RunDescriptor::new("run-pending-openhands", 1)) + .await + .expect("prepared run"); + let envelope: TerminalRuntimeEnvelope = serde_json::from_value(serde_json::json!({ + "repository_binding": { + "alias": "main", + "repository": { + "id": "github:repository:repo", + "safe_remote_fingerprint": "sha256:fingerprint" + }, + "config_generation": "config", + "inventory_generation": "inventory" + }, + "config_generation": "config", + "inventory_generation": "inventory", + "policy_generation": "config", + "checkout_generation": "generation-1", + "checkout_path": workspace.workspace_path(), + "target_branch": "develop", + "target_commit": "commit", + "instruction": { + "path": "AGENTS.md", + "content_hash": "sha256:instructions", + "source_commit": "commit", + "source": "root", + "native_discovery_paths": [], + "native_discovery_hashes": {} + }, + "harness": "openhands_agent_server", + "model_profile": "default", + "requested_execution_scope": "single_checkout", + "effective_containment": "trusted_host_process_cwd", + "cleanup_intent": "workspace_manager_owned" + })) + .expect("runtime envelope"); + run.runtime_envelope = Some(envelope.clone()); + manager + .write_run_manifest(&workspace, &run) + .await + .expect("run envelope"); + let mut pending = sample_conversation_manifest("conv-pending-openhands"); + pending.issue_id = issue.id.clone(); + pending.identifier = issue.identifier.clone(); + pending.workflow_prompt_seeded = false; + let mut pending_envelope = envelope; + pending_envelope.conversation_binding = Some(pending.conversation_id.to_string()); + if !valid { + pending_envelope.target_commit = "different-commit".into(); + } + pending.runtime_envelope = Some(pending_envelope); + manager + .write_json_artifact_atomically( + &workspace, + &pending_conversation_manifest_path(&workspace), + &Some(&pending), + ) + .await + .expect("pending ownership"); + + assert_eq!( + requires_openhands_recovery(&manager) + .await + .expect("preflight"), + valid + ); + assert_eq!( + manager + .read_text_artifact(&workspace, &workspace.conversation_manifest_path()) + .await + .expect("conversation") + .is_some(), + valid, + "only a matching pending owner is promoted" + ); + } + } + #[tokio::test] async fn recover_workspaces_discovers_completed_nested_parent_roots() { let tempdir = TempDir::new().expect("tempdir should exist"); @@ -14885,6 +15650,7 @@ Run the scheduler. let openhands_route = crate::opensymphony_orchestrator::HarnessRouteDecision { task_type: "issue_execution".into(), harness_kind: "openhands_agent_server".into(), + harness_profile: None, model: None, model_profile: None, reason: "test default route".into(), @@ -15953,6 +16719,7 @@ Run the scheduler. crate::opensymphony_orchestrator::HarnessRouteDecision { task_type: "issue_execution".into(), harness_kind: "codex_app_server".into(), + harness_profile: None, model: None, model_profile: Some("codex-chatgpt-local-keychain".into()), reason: "test codex route".into(), @@ -16586,6 +17353,2198 @@ exit 64 } } + fn acp_test_request(root: &Path, profile: &str, ordinal: u32) -> WorkerStartRequest { + let issue = sample_issue(); + let workspace = sample_workspace(root); + WorkerStartRequest { + run: RunAttempt::new( + WorkerId::new(format!("acp-worker-{ordinal}")).expect("worker"), + issue.id.clone(), + issue.identifier.clone(), + workspace.path.clone(), + TimestampMs::new(ordinal as u64), + None, + 8, + ), + issue, + workspace, + route: crate::opensymphony_orchestrator::HarnessRouteDecision { + task_type: "issue_execution".into(), + harness_kind: "acp".into(), + harness_profile: Some(profile.into()), + model: None, + model_profile: None, + reason: "ACP integration test".into(), + dry_run: false, + user_override: false, + }, + memory_grant_registry_recovered: false, + expected_parent_conversation_id: None, + parent_repair: None, + } + } + + async fn acp_test_backend(temp: &Path) -> (RuntimeWorkerBackend, Arc) { + let root = temp.join("workspaces"); + let mut workflow = sample_workflow(temp, &root); + for id in [ + "first", + "second", + "hang", + "crash", + "setup_retry", + "permission", + "corrupt", + "configured", + "slow_model_hang", + "unprompted_retry", + "future_stop", + "parent_unique", + "secret_stop", + ] { + let profile = serde_json::from_value(serde_json::json!({ + "command":"python3", "args":[format!("{}/tests/fixtures/acp_worker_peer.py", env!("CARGO_MANIFEST_DIR")), id] + })).expect("profile"); + workflow.extensions.acp.profiles.insert(id.into(), profile); + } + workflow + .extensions + .acp + .profiles + .get_mut("configured") + .expect("configured profile") + .session + .model = Some("profile-model".into()); + workflow + .extensions + .acp + .profiles + .get_mut("secret_stop") + .expect("secret stop profile") + .env_refs + .insert("AUDIT_TOKEN".into(), "ACP_TEST_AUDIT_TOKEN".into()); + workflow + .extensions + .acp + .profiles + .get_mut("slow_model_hang") + .expect("slow model profile") + .session + .model = Some("profile-model".into()); + workflow + .extensions + .acp + .profiles + .get_mut("unprompted_retry") + .expect("unprompted retry profile") + .session + .model = Some("profile-model".into()); + workflow.config.routing.harness = "acp".into(); + workflow.config.routing.harness_profile = Some("first".into()); + let manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)).expect("manager"), + ); + let backend = RuntimeWorkerBackend::new_with_client( + None, + Arc::new(workflow), + manager.clone(), + None, + BTreeMap::new(), + ); + let mut backend = backend; + backend.memory_env = Some(RuntimeMemoryEnv { + endpoint: "http://127.0.0.1:8765/mcp".into(), + token: None, + project: "project-scope".into(), + execution_repo: "repo-scope".into(), + parent_scope: false, + authorized_repositories: BTreeSet::from(["repo-scope".into()]), + authorized_repositories_by_project: BTreeMap::new(), + scope_grants: None, + project_set: None, + visibility: crate::opensymphony_memory::MemoryVisibility::Private, + run_id: None, + attempt: None, + target_commit: None, + checkout_head: None, + }); + (backend, manager) + } + + async fn acp_test_finished(backend: &mut RuntimeWorkerBackend) -> WorkerOutcomeRecord { + for _ in 0..200 { + for update in backend.poll_updates().await.expect("updates") { + if let WorkerUpdate::Finished { outcome, .. } = update { + return outcome; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("ACP worker did not finish") + } + + #[tokio::test] + async fn acp_worker_records_future_stop_reason_without_uncertain_submission() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "future_stop", 1)) + .await + .expect("ACP launch"); + let outcome = acp_test_finished(&mut backend).await; + assert_eq!(outcome.outcome, WorkerOutcomeKind::Detached); + assert!(outcome.harness_stopped); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let conversation = manager + .load_conversation_manifest(&handle) + .await + .expect("conversation") + .expect("manifest") + .acp + .expect("ACP state"); + assert_eq!( + conversation.status, + crate::opensymphony_workspace::AcpSessionStatus::Finished + ); + assert_eq!( + conversation.stop_reason.as_deref(), + Some("future_stop_reason") + ); + let run = manager + .load_run_manifest(&handle) + .await + .expect("run") + .expect("run manifest"); + assert_eq!(run.status, RunStatus::Failed); + assert!(run.harness_stopped); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_worker_redacts_credential_from_unknown_stop_reason_and_summary() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let secret = "dummy-acp-audit-credential-611"; + backend + .worker_env + .insert("ACP_TEST_AUDIT_TOKEN".into(), secret.into()); + backend + .start_worker(acp_test_request(&manager.config().root, "secret_stop", 1)) + .await + .expect("ACP launch"); + let outcome = acp_test_finished(&mut backend).await; + assert_eq!(outcome.outcome, WorkerOutcomeKind::Detached); + assert!(outcome.harness_stopped); + let summary = outcome.summary.expect("worker summary"); + assert!(summary.contains("vendor_error_[redacted]"), "{summary}"); + assert!(!summary.contains(secret)); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let durable = fs::read_to_string(handle.conversation_manifest_path()).expect("manifest"); + assert!(!durable.contains(secret)); + assert!(durable.contains("vendor_error_[redacted]")); + let run = manager + .load_run_manifest(&handle) + .await + .expect("run") + .expect("run manifest"); + assert!( + !serde_json::to_string(&run) + .expect("run JSON") + .contains(secret) + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_worker_applies_route_model_and_host_services_before_prompt() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + backend.memory_env.as_mut().expect("managed memory").token = Some("scoped-grant".into()); + let root = manager.config().root.clone(); + let mut request = acp_test_request(&root, "configured", 1); + request.route.model = Some("route-model".into()); + let launch = backend + .start_worker(request) + .await + .expect("configured launch"); + assert!( + launch + .conversation + .harness_capability + .as_ref() + .expect("negotiated capability") + .model_selection + ); + let mut outcome = None; + let mut command_started = None; + let mut command_finished = None; + let mut filesystem_activity = 0; + for _ in 0..200 { + for update in backend.poll_updates().await.expect("updates") { + match update { + WorkerUpdate::RuntimeEvent { + event_kind, + summary, + payload, + .. + } => match event_kind.as_deref() { + Some("acp.command_started") => command_started = payload, + Some("acp.command_finished") => command_finished = payload, + Some("acp.callback_activity") => { + assert!(payload.is_none()); + assert!(summary.as_deref().is_some_and(|summary| { + summary.starts_with("ACP filesystem callback") + })); + filesystem_activity += 1; + } + _ => {} + }, + WorkerUpdate::Finished { + outcome: finished, .. + } => outcome = Some(finished), + _ => {} + } + } + if outcome.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + outcome.expect("ACP worker outcome").outcome, + WorkerOutcomeKind::Succeeded + ); + assert_eq!( + filesystem_activity, 4, + "read and write request/response activity" + ); + assert!(command_started.as_ref().is_some_and(|payload| { + payload["command"] + .as_str() + .is_some_and(|command| command.contains("configured terminal")) + && payload["command_id"].as_str().is_some() + })); + assert_eq!( + command_finished + .as_ref() + .map(|payload| &payload["exit_code"]), + Some(&serde_json::json!(0)) + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")) + .expect("peer evidence"), + ) + .expect("json"); + assert_eq!(evidence["selected_model"], "route-model"); + assert_eq!(evidence["memory_mcp_attached"], true); + assert_eq!(evidence["callback_roundtrip"], true); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-callback.txt")).expect("file"), + "configured worker" + ); + let first_state = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state"); + assert!(first_state.model_selection); + let first_owner = first_state.owner_id; + + backend + .start_worker(acp_test_request(&root, "configured", 2)) + .await + .expect("profile-model launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")) + .expect("peer evidence"), + ) + .expect("json"); + assert_eq!(evidence["selected_model"], "profile-model"); + let second_owner = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state") + .owner_id; + assert_ne!(first_owner, second_owner); + let prompts = + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"); + let mut unsupported = acp_test_request(&root, "configured", 3); + unsupported.route.model = Some("unsupported-model".into()); + backend + .start_worker(unsupported) + .await + .expect("unsupported model setup"); + let outcome = acp_test_finished(&mut backend).await; + assert_eq!(outcome.outcome, WorkerOutcomeKind::Failed); + assert!(outcome.summary.as_deref().is_some_and(|summary| { + summary.contains("explicit session") && summary.contains("not advertised") + })); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"), + prompts, + "unsupported model must fail before submission" + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_workers_cover_default_scheduler_concurrency_without_cross_routing() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let requests = (0..10) + .map(|ordinal| { + let mut request = acp_test_request(&root, "first", ordinal); + let identifier = format!("COE-ACP-{ordinal}"); + request.issue.id = IssueId::new(format!("acp-issue-{ordinal}")).expect("issue"); + request.issue.identifier = + crate::opensymphony_domain::IssueIdentifier::new(&identifier) + .expect("identifier"); + request.workspace.path = root.join(&identifier); + request.workspace.workspace_key = WorkspaceKey::new(&identifier).expect("key"); + request.run = RunAttempt::new( + WorkerId::new(format!("acp-worker-{ordinal}")).expect("worker"), + request.issue.id.clone(), + request.issue.identifier.clone(), + request.workspace.path.clone(), + TimestampMs::new(ordinal as u64), + None, + 8, + ); + request + }) + .collect(); + let launches = backend.start_workers(requests).await; + assert!(launches.iter().all(Result::is_ok), "{launches:?}"); + let mut finished = BTreeSet::new(); + for _ in 0..300 { + for update in backend.poll_updates().await.expect("updates") { + if let WorkerUpdate::Finished { worker_id, outcome } = update { + assert_eq!(outcome.outcome, WorkerOutcomeKind::Succeeded); + finished.insert(worker_id.to_string()); + } + } + if finished.len() == 10 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(finished.len(), 10); + let workspaces = manager.list_all_workspaces().await.expect("workspaces"); + assert_eq!(workspaces.len(), 10); + for (handle, _) in workspaces { + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")) + .expect("evidence"), + ) + .expect("JSON"); + assert_eq!( + evidence["cwd"], + handle.workspace_path().to_str().expect("path") + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + } + + #[tokio::test] + async fn acp_restored_unprompted_session_receives_full_workflow_prompt() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "unprompted_retry", 1)) + .await + .expect("first owner reached Ready"); + let first_outcome = acp_test_finished(&mut backend).await; + assert_eq!(first_outcome.outcome, WorkerOutcomeKind::Failed); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let first = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("conversation"); + let state = first.acp.expect("ACP state"); + assert_eq!( + state.status, + crate::opensymphony_workspace::AcpSessionStatus::Ready + ); + assert!( + state.session_id.is_some(), + "session/new completed: {state:?}; outcome: {first_outcome:?}" + ); + assert!(!handle.workspace_path().join("acp-prompts.jsonl").exists()); + + backend + .start_worker(acp_test_request(&root, "unprompted_retry", 2)) + .await + .expect("restored owner launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let restored = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("conversation") + .acp + .expect("ACP state"); + assert_eq!( + restored.recovery, + crate::opensymphony_workspace::AcpRecovery::RestoredLoad + ); + let prompts = fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .expect("recorded prompt"); + let sent: serde_json::Value = + serde_json::from_str(prompts.lines().next().expect("first prompt")).expect("JSON"); + let prompt = sent["prompt"].as_str().expect("prompt text"); + assert!(prompt.contains("# Test Workflow"), "{prompt}"); + assert!(prompt.contains("Run the scheduler."), "{prompt}"); + assert_eq!(prompts.lines().count(), 1); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_restored_seeded_session_receives_continuation_prompt() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "first", 1)) + .await + .expect("first owner"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let first = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("conversation") + .acp + .expect("ACP"); + assert!(first.workflow_prompt_seeded()); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire first owner"); + + backend + .start_worker(acp_test_request(&root, "first", 2)) + .await + .expect("restored owner"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let restored = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("conversation") + .acp + .expect("ACP"); + assert_eq!( + restored.recovery, + crate::opensymphony_workspace::AcpRecovery::RestoredLoad + ); + assert!(restored.workflow_prompt_seeded()); + let prompts = + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"); + assert_eq!(prompts.lines().count(), 2); + let second: serde_json::Value = + serde_json::from_str(prompts.lines().nth(1).expect("second prompt")).expect("JSON"); + let prompt = second["prompt"].as_str().expect("prompt"); + assert!(prompt.contains("Continue the current issue"), "{prompt}"); + assert!(!prompt.contains("# Test Workflow"), "{prompt}"); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn retained_acp_parent_binding_preserves_current_hierarchy_and_checkouts() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "first", 1)) + .await + .expect("first turn"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let mut conversation = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("conversation"); + let mut prior = parent_envelope(handle.workspace_path()); + prior.harness = "acp".into(); + prior.conversation_binding = conversation.acp.as_ref().expect("ACP").session_id.clone(); + conversation.parent_runtime_envelope = Some(prior.clone()); + let mut current = manager + .load_run_manifest(&handle) + .await + .expect("run") + .expect("manifest"); + current.run_id = "run-acp-worker-2".into(); + current.attempt = 2; + let mut current_parent = prior; + current_parent.run_id = current.run_id.clone(); + current_parent.attempt = current.attempt; + current_parent.hierarchy_generation = 8; + current_parent + .checkouts + .get_mut("checkout-one") + .expect("checkout") + .target_commit = "new-commit".into(); + current_parent.conversation_binding = None; + current.parent_runtime_envelope = Some(current_parent.clone()); + + acp::bind_current_run_envelopes(&mut current, &conversation); + assert_eq!( + current.parent_runtime_envelope.as_ref(), + Some(¤t_parent), + "prior turn cannot bind a new run" + ); + let state = conversation.acp.as_mut().expect("ACP"); + state.identity.run_id = current.run_id.clone(); + state.identity.attempt = current.attempt; + acp::bind_current_run_envelopes(&mut current, &conversation); + let bound = current.parent_runtime_envelope.expect("current envelope"); + assert_eq!(bound.hierarchy_generation, 8); + assert_eq!(bound.checkouts["checkout-one"].target_commit, "new-commit"); + assert_eq!(bound.run_id, "run-acp-worker-2"); + assert_eq!( + bound.conversation_binding, + conversation.acp.expect("ACP").session_id + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_worker_retains_continuation_switches_profiles_and_recovers_persisted_route() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let first = backend + .start_worker(acp_test_request(&root, "first", 1)) + .await + .expect("first launch"); + assert_eq!( + first + .conversation + .harness_capability + .as_ref() + .expect("capability") + .profile_id, + "first" + ); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let owner = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("state") + .owner_id; + backend + .start_worker(acp_test_request(&root, "first", 2)) + .await + .expect("continuation"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let retained = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("state"); + assert_eq!(retained.owner_id, owner); + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")).expect("evidence"), + ) + .expect("json"); + assert!( + evidence["prompt"] + .as_str() + .expect("prompt") + .contains("Continue the current issue") + ); + assert_eq!(evidence["memory_project"], "project-scope"); + assert_eq!(evidence["memory_repo"], "repo-scope"); + let mut dry_run = acp_test_request(&root, "second", 99); + dry_run.route.dry_run = true; + backend + .start_worker(dry_run) + .await + .expect("dry-run alternate profile"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + assert_eq!( + manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("retained owner") + .owner_id, + owner, + "dry-run must not retire or replace the owner" + ); + + backend + .start_worker(acp_test_request(&root, "second", 3)) + .await + .expect("profile switch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let switched = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("state"); + assert_eq!(switched.identity.profile_id, "second"); + assert_ne!(switched.owner_id, owner); + // A process restart between the durable terminal response and worker finish must + // recover the stored second profile even if the new default is first. + let mut prior = manager + .load_run_manifest(&handle) + .await + .expect("run") + .expect("run"); + prior.status = RunStatus::Running; + manager + .write_run_manifest(&handle, &prior) + .await + .expect("persist"); + let recovered = backend + .recover_worker(acp_test_request(&root, "first", 3)) + .await + .expect("recovery"); + let capability = recovered + .conversation + .harness_capability + .expect("durable capability"); + assert_eq!(capability.profile_id, "second"); + assert!(capability.session_restore); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let prompts = + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"); + assert_eq!( + prompts.lines().count(), + 3, + "recovery cannot replay a completed prompt" + ); + assert!( + acp::retire(&manager, &handle, None).await.is_err(), + "a different host cannot retire a live owner, even after a finished turn" + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + let mut exited = std::process::Command::new("true").spawn().expect("child"); + let pid = exited.id(); + exited.wait().expect("exited child"); + let mut manifest = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest"); + manifest.acp.as_mut().expect("ACP").process = + crate::opensymphony_workspace::AcpProcessState::Running { pid }; + manager + .write_json_artifact_atomically( + &handle, + &handle.conversation_manifest_path(), + &manifest, + ) + .await + .expect("simulate owner death before stop checkpoint"); + acp::retire(&manager, &handle, None) + .await + .expect("retire absent prior process without launch"); + assert_eq!( + manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP") + .process, + crate::opensymphony_workspace::AcpProcessState::Stopped + ); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"), + prompts + ); + } + + #[tokio::test] + async fn acp_worker_rotates_retained_owner_when_effective_profile_identity_changes() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let source = "OPENSYMPHONY_ACP_TEST_SCOPE"; + backend + .worker_env + .insert(source.into(), "credential-one".into()); + Arc::make_mut(&mut backend.workflow) + .extensions + .acp + .profiles + .get_mut("first") + .expect("profile") + .env_refs + .insert("ACP_AUTH".into(), source.into()); + backend + .start_worker(acp_test_request(&root, "first", 1)) + .await + .expect("first launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let first = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP"); + + backend + .worker_env + .insert(source.into(), "credential-two".into()); + backend + .start_worker(acp_test_request(&root, "first", 2)) + .await + .expect("changed credential launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let second = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP"); + assert_ne!(first.owner_id, second.owner_id); + assert_ne!( + first.identity.credential_scope, + second.identity.credential_scope + ); + + Arc::make_mut(&mut backend.workflow) + .extensions + .acp + .profiles + .get_mut("first") + .expect("profile") + .args + .push("config-v2".into()); + backend + .start_worker(acp_test_request(&root, "first", 3)) + .await + .expect("changed profile launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let third = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP"); + assert_ne!(second.owner_id, third.owner_id); + assert_ne!( + second.identity.profile_fingerprint, + third.identity.profile_fingerprint + ); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts") + .lines() + .count(), + 3 + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_worker_rotates_retained_owner_when_memory_run_scope_changes() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let mut previous_owner = None; + for ordinal in 1..=4 { + let memory = backend.memory_env.as_mut().expect("managed memory"); + match ordinal { + 1 => memory.run_id = Some("run-a".into()), + 2 => memory.run_id = Some("run-b".into()), + 3 => memory.attempt = Some(2), + 4 => memory.project_set = Some("project-set-b".into()), + _ => unreachable!(), + } + backend + .start_worker(acp_test_request(&root, "first", ordinal)) + .await + .expect("ACP launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let current = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state"); + if let Some(previous) = previous_owner.as_ref() { + assert_ne!(¤t.owner_id, previous); + } + previous_owner = Some(current.owner_id); + if ordinal == 4 { + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + } + } + + #[tokio::test] + async fn acp_parent_refreshes_managed_memory_without_replacing_bound_session() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let mut request = acp_test_request(&manager.config().root, "parent_unique", 1); + request + .issue + .sub_issues + .push(crate::opensymphony_domain::IssueRef { + id: IssueId::new("child-id").expect("child id"), + identifier: IssueIdentifier::new("COE-CHILD").expect("child identifier"), + state: "Done".into(), + }); + let parent = manager + .prepare_parent_execution_root(&issue_descriptor(&request.issue), 1, Vec::new()) + .await + .expect("parent root"); + let workspace = parent.handle; + request.workspace.path = workspace.workspace_path().to_path_buf(); + request.workspace.workspace_key = + WorkspaceKey::new(workspace.workspace_key()).expect("parent key"); + request.run.workspace_path = workspace.workspace_path().to_path_buf(); + let first = backend + .start_worker(request.clone()) + .await + .expect("first launch"); + let bound_id = first.conversation.conversation_id.to_string(); + let _ = acp_test_finished(&mut backend).await; + let first_owner = manager + .load_conversation_manifest(&workspace) + .await + .expect("manifest") + .expect("conversation") + .acp + .expect("ACP state"); + + let mut continuation = request; + continuation.run = RunAttempt::new( + WorkerId::new("acp-parent-continuation").expect("worker"), + continuation.issue.id.clone(), + continuation.issue.identifier.clone(), + workspace.workspace_path().to_path_buf(), + TimestampMs::new(2), + Some(RetryAttempt::new(2).expect("retry")), + 8, + ); + continuation.expected_parent_conversation_id = Some(bound_id.clone()); + let second = backend + .start_worker(continuation) + .await + .expect("parent continuation launch"); + assert_eq!(second.conversation.conversation_id.to_string(), bound_id); + let _ = acp_test_finished(&mut backend).await; + let calls = + fs::read_to_string(workspace.workspace_path().join("acp-session-methods.jsonl")) + .expect("peer session calls") + .lines() + .map(|line| serde_json::from_str::(line).expect("call")) + .collect::>(); + assert_eq!(calls.len(), 2, "one new session and one restore: {calls:?}"); + assert_eq!(calls[0]["method"], "session/new"); + assert_eq!(calls[1]["method"], "session/load"); + assert_eq!(calls[1]["session"], bound_id); + assert!( + fs::read_dir(workspace.metadata_dir()) + .expect("metadata") + .flatten() + .any(|entry| entry + .file_name() + .to_string_lossy() + .starts_with("acp-retired-")), + "rotated parent owner should retain an audit snapshot" + ); + let retained = manager + .load_conversation_manifest(&workspace) + .await + .expect("manifest") + .expect("conversation") + .acp + .expect("ACP state"); + assert_ne!(retained.owner_id, first_owner.owner_id); + assert_ne!( + retained.identity.credential_scope, + first_owner.identity.credential_scope + ); + assert_eq!(retained.session_id.as_deref(), Some(bound_id.as_str())); + assert_eq!( + retained.recovery, + crate::opensymphony_workspace::AcpRecovery::RestoredLoad + ); + acp::retire(&manager, &workspace, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_fresh_memory_grant_is_acknowledged_after_owner_launch() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let grants = MemoryScopeGrantRegistry::default(); + backend.memory_env = Some(RuntimeMemoryEnv { + endpoint: "http://127.0.0.1:8765/mcp".into(), + token: None, + project: "project-scope".into(), + project_set: None, + visibility: crate::opensymphony_memory::MemoryVisibility::Private, + run_id: None, + attempt: None, + target_commit: None, + checkout_head: None, + execution_repo: "repo-scope".into(), + parent_scope: false, + authorized_repositories: BTreeSet::from(["repo-scope".into()]), + authorized_repositories_by_project: BTreeMap::new(), + scope_grants: Some(grants.clone()), + }); + backend + .start_worker(acp_test_request(&root, "first", 1)) + .await + .expect("first launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let first_owner = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state") + .owner_id; + assert!(grants.revoke_issue("COE-284")); + assert!(grants.fresh_conversation_required("COE-284")); + + backend + .start_worker(acp_test_request(&root, "first", 2)) + .await + .expect("fresh owner launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + assert!(!grants.fresh_conversation_required("COE-284")); + let second_owner = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state") + .owner_id; + assert_ne!(first_owner, second_owner); + + backend + .start_worker(acp_test_request(&root, "first", 3)) + .await + .expect("retained owner launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let third_owner = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state") + .owner_id; + assert_eq!(second_owner, third_owner); + assert!(grants.revoke_issue("COE-284")); + assert!( + backend + .start_worker(acp_test_request(&root, "missing", 4)) + .await + .is_err(), + "an unavailable profile cannot acknowledge a fresh grant" + ); + assert!(grants.fresh_conversation_required("COE-284")); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_worker_ignores_unmanaged_ambient_memory_scope() { + let output = Command::new(env::current_exe().expect("test binary")) + .args([ + "--exact", + "opensymphony_cli::orchestrator_run::backends::tests::acp_worker_ignores_unmanaged_ambient_memory_scope_child", + "--nocapture", + ]) + .env("ACP_TEST_AMBIENT_SCOPE", "1") + .env("OPENSYMPHONY_MEMORY_PROJECT", "stale-project") + .env("OPENSYMPHONY_MEMORY_EXECUTION_REPO", "stale-repo") + .env("OPENSYMPHONY_MEMORY_ENDPOINT", "http://stale.invalid/mcp") + .env("OPENSYMPHONY_MEMORY_TOKEN", "stale-grant") + .env("OPENSYMPHONY_MEMORY_ADMIN_TOKEN", "admin-bearer") + .output() + .await + .expect("child test"); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); + } + + #[tokio::test] + async fn acp_worker_ignores_unmanaged_ambient_memory_scope_child() { + if env::var_os("ACP_TEST_AMBIENT_SCOPE").is_none() { + return; + } + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + backend.worker_env.clear(); + backend.memory_env = None; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "configured", 1)) + .await + .expect("ACP launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")).expect("evidence"), + ) + .expect("JSON"); + let prompt = evidence["prompt"].as_str().expect("prompt"); + assert!(!prompt.contains("Memory tool scope:")); + assert!(!prompt.contains("stale-project")); + assert!(!prompt.contains("stale-repo")); + assert_eq!(evidence["memory_mcp_attached"], false); + assert_eq!(evidence["memory_admin_present"], false); + assert_eq!(evidence["memory_token_present"], false); + assert_eq!(evidence["memory_endpoint_present"], false); + assert_eq!(evidence["callback_roundtrip"], true); + assert_eq!(evidence["callback_admin_present"], false); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_recovery_does_not_complete_a_prepared_run_from_the_prior_turn() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "first", 1)) + .await + .expect("first launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let prior = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state"); + assert_eq!( + prior.status, + crate::opensymphony_workspace::AcpSessionStatus::Finished + ); + assert_eq!(prior.identity.run_id, "run-acp-worker-1"); + let prepared = manager + .start_run( + &handle, + &RunDescriptor::new("run-acp-worker-2", 2).with_acp_route(Some(acp_run_route( + &acp_test_request(&root, "first", 2).route, + ))), + ) + .await + .expect("prepare next run before daemon restart"); + assert_eq!(prepared.status, RunStatus::Prepared); + backend.memory_env = Some(RuntimeMemoryEnv { + endpoint: "http://127.0.0.1:8765/mcp".into(), + token: Some("new-scoped-grant".into()), + project: "project-scope".into(), + project_set: None, + visibility: crate::opensymphony_memory::MemoryVisibility::Private, + run_id: None, + attempt: None, + target_commit: None, + checkout_head: None, + execution_repo: "repo-scope".into(), + parent_scope: false, + authorized_repositories: BTreeSet::from(["repo-scope".into()]), + authorized_repositories_by_project: BTreeMap::new(), + scope_grants: None, + }); + let recovered = backend + .recover_worker(acp_test_request(&root, "first", 2)) + .await + .expect("recover prepared run"); + assert_eq!( + recovered + .conversation + .harness_capability + .as_ref() + .expect("ACP") + .profile_id, + "first" + ); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let prompts = + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"); + assert_eq!( + prompts.lines().count(), + 2, + "new prepared run must submit its own prompt" + ); + let current = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state"); + assert_eq!(current.identity.run_id, "run-acp-worker-2"); + assert_eq!(current.identity.attempt, 2); + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")) + .expect("worker evidence"), + ) + .expect("JSON evidence"); + assert_eq!(evidence["memory_token_present"], true); + assert_eq!(evidence["memory_mcp_attached"], true); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_recovery_uses_prepared_run_model_instead_of_prior_turn_route() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "configured", 1)) + .await + .expect("first launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let old_route: crate::opensymphony_orchestrator::HarnessRouteDecision = + serde_json::from_str( + &manager + .read_text_artifact(&handle, &handle.metadata_dir().join("harness-route.json")) + .await + .expect("route artifact") + .expect("prior route"), + ) + .expect("route JSON"); + assert_eq!(old_route.model.as_deref(), Some("profile-model")); + + let mut selected = acp_test_request(&root, "configured", 2); + selected.route.model = Some("route-model".into()); + let prepared = manager + .start_run( + &handle, + &RunDescriptor::new("run-acp-worker-2", 2) + .with_acp_route(Some(acp_run_route(&selected.route))), + ) + .await + .expect("prepare selected run before crash"); + assert_eq!(prepared.status, RunStatus::Prepared); + assert_eq!( + prepared + .acp_route + .as_ref() + .and_then(|route| route.model.as_deref()), + Some("route-model") + ); + let recovered = backend + .recover_worker(acp_test_request(&root, "configured", 2)) + .await + .expect("recover prepared run"); + assert_eq!( + recovered + .conversation + .harness_capability + .expect("ACP capability") + .profile_id, + "configured" + ); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")) + .expect("peer evidence"), + ) + .expect("evidence JSON"); + assert_eq!(evidence["selected_model"], "route-model"); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts") + .lines() + .count(), + 2 + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_recovery_keeps_profile_model_selected_when_run_was_prepared() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "configured", 1)) + .await + .expect("first launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let first_run = manager + .load_run_manifest(&handle) + .await + .expect("manifest") + .expect("run"); + assert_eq!( + first_run + .acp_route + .as_ref() + .and_then(|route| route.model.as_deref()), + Some("profile-model") + ); + let mut selected = acp_test_request(&root, "configured", 2); + bind_acp_profile_model(&mut selected.route, &backend.workflow); + let prepared = manager + .start_run( + &handle, + &RunDescriptor::new("run-acp-worker-2", 2) + .with_acp_route(Some(acp_run_route(&selected.route))), + ) + .await + .expect("prepare profile model before crash"); + assert_eq!( + prepared + .acp_route + .as_ref() + .and_then(|route| route.model.as_deref()), + Some("profile-model") + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("prior owner retired before recovery"); + Arc::make_mut(&mut backend.workflow) + .extensions + .acp + .profiles + .get_mut("configured") + .expect("profile") + .session + .model = Some("other-model".into()); + backend + .recover_worker(acp_test_request(&root, "configured", 2)) + .await + .expect("recover selected profile model"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(handle.workspace_path().join("acp-worker.json")) + .expect("peer evidence"), + ) + .expect("evidence JSON"); + assert_eq!(evidence["selected_model"], "profile-model"); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts") + .lines() + .count(), + 2 + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_recovery_rejects_unbound_prior_turn_route() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "first", 1)) + .await + .expect("first launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + manager + .start_run(&handle, &RunDescriptor::new("run-acp-worker-2", 2)) + .await + .expect("prepare unbound run"); + let result = backend + .recover_worker(acp_test_request(&root, "first", 2)) + .await; + assert!( + matches!(result, Err(CliWorkerError::LaunchFailed(message)) if message.contains("no route bound")) + ); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts") + .lines() + .count(), + 1 + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_recovery_preserves_durable_pre_prompt_cancellation_outcome() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let request = acp_test_request(&root, "first", 1); + backend + .start_worker(request.clone()) + .await + .expect("first launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let mut conversation = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest"); + let checkpoint = conversation.acp.as_mut().expect("ACP checkpoint"); + assert_eq!(checkpoint.identity.run_id, "run-acp-worker-1"); + checkpoint.stop_reason = Some("cancelled_before_prompt".into()); + manager + .write_json_artifact_atomically( + &handle, + &handle.conversation_manifest_path(), + &conversation, + ) + .await + .expect("durable cancellation checkpoint"); + let raw = manager + .read_text_artifact(&handle, &handle.conversation_manifest_path()) + .await + .expect("conversation artifact") + .expect("conversation"); + assert!( + acp::conversation_view(&raw) + .expect("scheduler view") + .workflow_prompt_seeded, + "cancelling a later prompt does not erase prior workflow context" + ); + let mut crashed_run = manager + .load_run_manifest(&handle) + .await + .expect("run") + .expect("run"); + crashed_run.status = RunStatus::Running; + manager + .write_run_manifest(&handle, &crashed_run) + .await + .expect("simulate interrupted worker finish"); + + backend + .recover_worker(request) + .await + .expect("recover terminal checkpoint"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Cancelled + ); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts") + .lines() + .count(), + 1 + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_interrupt_waits_for_retained_pre_submission_cancellation() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let first_request = acp_test_request(&root, "slow_model_hang", 1); + let first = backend + .start_worker(first_request.clone()) + .await + .expect("first launch"); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + timeout(Duration::from_secs(5), async { + while !handle.workspace_path().join("acp-worker.json").exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("first prompt submitted"); + backend + .interrupt_worker(HarnessInterruptCommand { + run_id: first_request.issue.identifier.to_string(), + issue_id: first_request.issue.id.clone(), + harness_kind: "acp".into(), + conversation_id: first.conversation.conversation_id, + turn_id: None, + reason: HarnessInterruptReason::OperatorCancel, + expected_next_state: HarnessInterruptExpectedNextState::Paused, + }) + .await + .expect("first interrupt"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Cancelled + ); + + let next_request = acp_test_request(&root, "slow_model_hang", 2); + let next = backend + .start_worker(next_request.clone()) + .await + .expect("retained launch"); + timeout(Duration::from_secs(5), async { + while !handle.workspace_path().join("acp-config-waiting").exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("retained configuration is preparing"); + let before_cancel = manager + .load_conversation_manifest(&handle) + .await + .expect("manifest") + .expect("manifest") + .acp + .expect("ACP state"); + assert_eq!(before_cancel.identity.run_id, "run-acp-worker-1"); + assert_eq!( + before_cancel.status, + crate::opensymphony_workspace::AcpSessionStatus::Finished + ); + let acknowledgement = backend + .interrupt_worker(HarnessInterruptCommand { + run_id: next_request.issue.identifier.to_string(), + issue_id: next_request.issue.id.clone(), + harness_kind: "acp".into(), + conversation_id: next.conversation.conversation_id, + turn_id: None, + reason: HarnessInterruptReason::OperatorCancel, + expected_next_state: HarnessInterruptExpectedNextState::Paused, + }) + .await + .expect("retained interrupt"); + assert!(acknowledgement.accepted); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Cancelled + ); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts") + .lines() + .count(), + 1, + "cancelled preparation must not submit the next prompt" + ); + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_worker_interrupt_abort_and_uncertain_cleanup_are_owner_fenced() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + let request = acp_test_request(&root, "hang", 1); + let launch = backend + .start_worker(request.clone()) + .await + .expect("hang launch"); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + for _ in 0..100 { + if handle.workspace_path().join("acp-worker.json").exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .is_err() + ); + let command = HarnessInterruptCommand { + run_id: request.issue.identifier.to_string(), + issue_id: request.issue.id.clone(), + harness_kind: "acp".into(), + conversation_id: launch.conversation.conversation_id, + turn_id: None, + reason: HarnessInterruptReason::OperatorCancel, + expected_next_state: HarnessInterruptExpectedNextState::Paused, + }; + backend + .interrupt_worker(HarnessInterruptCommand { + run_id: "run-acp-worker-1".into(), + ..command.clone() + }) + .await + .expect_err("generated worker run ID is not the scheduler identity"); + let acknowledgement = backend.interrupt_worker(command).await.expect("interrupt"); + assert!(acknowledgement.accepted); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Cancelled + ); + backend + .start_worker(acp_test_request(&root, "hang", 2)) + .await + .expect("next hang"); + timeout(Duration::from_secs(5), async { + loop { + if fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) + .is_ok_and(|prompts| prompts.lines().count() == 2) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("second prompt submitted"); + backend + .abort_worker( + &WorkerId::new("acp-worker-2").expect("worker"), + WorkerAbortReason::TrackerTerminal, + ) + .await + .expect("abort"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Cancelled + ); + backend + .start_worker(acp_test_request(&root, "crash", 3)) + .await + .expect("crash launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Detached + ); + assert!( + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .is_err() + ); + let prompts = + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"); + backend + .recover_worker(acp_test_request(&root, "first", 3)) + .await + .expect("fenced recovery"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Detached + ); + assert_eq!( + fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")).expect("prompts"), + prompts + ); + } + + #[tokio::test] + async fn acp_poll_removes_failed_task_and_uses_owner_stop_evidence() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + backend + .start_worker(acp_test_request(&manager.config().root, "hang", 1)) + .await + .expect("launch"); + let worker_id = WorkerId::new("acp-worker-1").expect("worker"); + let session = backend + .acp_active + .lock() + .expect("sessions") + .get(worker_id.as_str()) + .expect("active") + .clone(); + backend + .tasks + .get(worker_id.as_str()) + .expect("task") + .handle + .abort(); + timeout(Duration::from_secs(5), async { + loop { + if session.cancellation.is_cancelled() + && matches!( + acp::observe_stop(&session, &manager) + .await + .expect("stop observation"), + acp::StopObservation::Stopped(_) + ) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("owner observes cancellation"); + let outcome = acp_test_finished(&mut backend).await; + assert_eq!(outcome.outcome, WorkerOutcomeKind::Failed); + assert!(outcome.harness_stopped); + assert!( + !backend + .acp_active + .lock() + .expect("sessions") + .contains_key(worker_id.as_str()) + ); + assert!( + backend + .poll_updates() + .await + .expect("second poll") + .is_empty() + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_abort_observes_failed_task_once() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + backend + .start_worker(acp_test_request(&manager.config().root, "hang", 1)) + .await + .expect("launch"); + let worker_id = WorkerId::new("acp-worker-1").expect("worker"); + backend + .tasks + .get(worker_id.as_str()) + .expect("task") + .handle + .abort(); + backend + .abort_worker(&worker_id, WorkerAbortReason::TrackerTerminal) + .await + .expect_err("join failure is reported"); + let updates = backend + .poll_updates() + .await + .expect("completed join is not polled twice"); + assert!(updates.iter().any(|update| matches!(update, + WorkerUpdate::Finished { worker_id: id, outcome } if id == &worker_id + && outcome.outcome == WorkerOutcomeKind::Failed && outcome.harness_stopped))); + assert!(!backend.tasks.contains_key(worker_id.as_str())); + assert!( + !backend + .acp_active + .lock() + .expect("sessions") + .contains_key(worker_id.as_str()) + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + + #[tokio::test] + async fn acp_worker_fences_unreadable_submission_evidence() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + backend + .start_worker(acp_test_request(&manager.config().root, "corrupt", 1)) + .await + .expect("launch"); + let outcome = acp_test_finished(&mut backend).await; + assert_eq!(outcome.outcome, WorkerOutcomeKind::Detached); + assert!( + !outcome.harness_stopped, + "unreadable durable evidence cannot prove stop" + ); + let handle = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + assert!(manager.load_conversation_manifest(&handle).await.is_err()); + assert!( + acp::retire(&manager, &handle, backend.acp_host.as_ref()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn acp_worker_retries_pre_submission_failure_and_reports_permission_wait() { + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + let root = manager.config().root.clone(); + backend + .start_worker(acp_test_request(&root, "setup_retry", 1)) + .await + .expect("failure reported through worker"); + let failed = acp_test_finished(&mut backend).await; + assert_eq!(failed.outcome, WorkerOutcomeKind::Failed); + let state = manager + .list_all_workspaces() + .await + .expect("workspaces") + .remove(0) + .0; + let state = manager + .load_conversation_manifest(&state) + .await + .expect("state"); + assert!(failed.harness_stopped, "{failed:?} {state:?}"); + backend + .start_worker(acp_test_request(&root, "setup_retry", 2)) + .await + .expect("safe retry"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + backend + .start_worker(acp_test_request(&root, "permission", 3)) + .await + .expect("permission launch"); + let mut waiting = false; + let mut finished = None; + for _ in 0..200 { + for update in backend.poll_updates().await.expect("updates") { + match update { + WorkerUpdate::RuntimeEvent { event_kind, .. } => { + waiting |= event_kind.as_deref() == Some("acp.waiting_for_input") + } + WorkerUpdate::Finished { outcome, .. } => finished = Some(outcome), + _ => {} + } + } + if finished.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(waiting, "permission waiting state reaches the scheduler"); + assert_eq!( + finished.expect("finished").outcome, + WorkerOutcomeKind::Cancelled + ); + } + + #[tokio::test] + async fn acp_worker_preserves_verified_checkout_instructions_and_memory_grant() { + use crate::opensymphony_domain::{RepositoryIdentity, SafeRemoteFingerprint}; + use crate::opensymphony_workspace::CheckoutRepository; + let temp = TempDir::new().expect("temp"); + let source = temp.path().join("source"); + let origin = temp.path().join("origin.git"); + fs::create_dir_all(&source).expect("source"); + let git = |cwd: &Path, args: &[&str]| { + let output = std::process::Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .expect("git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&source, &["init", "-b", "main"]); + git(&source, &["config", "user.email", "test@example.invalid"]); + git(&source, &["config", "user.name", "ACP test"]); + fs::write( + source.join("AGENTS.md"), + "Verified ACP repository instructions", + ) + .expect("instructions"); + git(&source, &["add", "."]); + git(&source, &["commit", "-m", "fixture"]); + git( + temp.path(), + &[ + "clone", + "--bare", + source.to_str().expect("source path"), + origin.to_str().expect("origin path"), + ], + ); + let remote = origin.to_str().expect("origin path"); + let binding = crate::opensymphony_domain::RepositoryBinding { + alias: "source".into(), + repository: RepositoryIdentity { + id: CanonicalRepositoryId::from_remote("local", None, remote).expect("repo id"), + safe_remote_fingerprint: SafeRemoteFingerprint::from_remote("local", None, remote) + .expect("remote fingerprint"), + }, + config_generation: "config-1".into(), + inventory_generation: "inventory-1".into(), + }; + let repository = CheckoutRepository { + provider: "local".into(), + provider_id: None, + remote_locator: remote.into(), + remote: remote.into(), + target_branch: "main".into(), + credential_kind: "environment".into(), + credential_reference: None, + credential_env: Some("HOME".into()), + review_credential_env: None, + instructions_path: "AGENTS.md".into(), + policy_generation: "policy-1".into(), + review_profile: "local".into(), + review_provider: "local".into(), + review_policy_generation: "review-1".into(), + required_checks: false, + required_review: false, + merge_method: None, + }; + let (mut backend, initial_manager) = acp_test_backend(temp.path()).await; + let manager = Arc::new( + WorkspaceManager::new(initial_manager.config().clone()) + .expect("manager") + .with_repository_checkouts(BTreeMap::from([( + binding.repository.id.to_string(), + repository, + )])), + ); + backend.workspace_manager = manager.clone(); + backend + .checkout_credential_envs + .insert("OPENSYMPHONY_CHECKOUT_TEST_ONLY".into()); + backend.worker_env.insert( + "OPENSYMPHONY_CHECKOUT_TEST_ONLY".into(), + "must-not-reach-agent".into(), + ); + backend.memory_env = Some(RuntimeMemoryEnv { + endpoint: "http://127.0.0.1:8765/mcp".into(), + token: None, + project: "project-scope".into(), + project_set: None, + visibility: crate::opensymphony_memory::MemoryVisibility::Private, + run_id: None, + attempt: None, + target_commit: None, + checkout_head: None, + execution_repo: binding.repository.id.to_string(), + parent_scope: false, + authorized_repositories: BTreeSet::from([binding.repository.id.to_string()]), + authorized_repositories_by_project: BTreeMap::new(), + scope_grants: Some(MemoryScopeGrantRegistry::default()), + }); + let mut request = acp_test_request(&manager.config().root, "first", 1); + request.issue.repository_binding = + Some(RepositoryBindingOutcome::Resolved(binding.clone())); + request.run.repository_binding = Some(binding.clone()); + let ensured = manager + .ensure_with_run_id(&issue_descriptor(&request.issue), Some("run-acp-worker-1")) + .await + .expect("verified checkout"); + request.run.workspace_path = ensured.handle.workspace_path().to_path_buf(); + request.workspace.path = request.run.workspace_path.clone(); + request.workspace.workspace_key = + WorkspaceKey::new(ensured.handle.workspace_key()).expect("key"); + let mut recovery_request = request.clone(); + backend + .start_worker(request) + .await + .expect("strict ACP launch"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + let evidence: serde_json::Value = serde_json::from_str( + &fs::read_to_string(ensured.handle.workspace_path().join("acp-worker.json")) + .expect("evidence"), + ) + .expect("JSON"); + assert!( + evidence["prompt"] + .as_str() + .expect("prompt") + .contains("Verified ACP repository instructions") + ); + assert_eq!(evidence["memory_repo"], binding.repository.id.to_string()); + assert_eq!(evidence["checkout_secret_present"], false); + assert_eq!(evidence["memory_token_present"], true); + let run = manager + .load_run_manifest(&ensured.handle) + .await + .expect("run") + .expect("run"); + let envelope = run.runtime_envelope.expect("runtime envelope"); + assert_eq!(envelope.repository_binding, binding); + assert_eq!(envelope.harness, "acp"); + assert!(envelope.acp_session.is_some()); + assert!(envelope.conversation_binding.is_some()); + let conversation = manager + .load_conversation_manifest(&ensured.handle) + .await + .expect("conversation") + .expect("conversation"); + assert_eq!(conversation.runtime_envelope.as_ref(), Some(&envelope)); + let prompts = fs::read_to_string(ensured.handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts"); + let mut interrupted_finish = manager + .load_run_manifest(&ensured.handle) + .await + .expect("run") + .expect("run"); + interrupted_finish.status = RunStatus::Running; + manager + .write_run_manifest(&ensured.handle, &interrupted_finish) + .await + .expect("crash window"); + recovery_request.route.harness_profile = Some("second".into()); + recovery_request.memory_grant_registry_recovered = true; + backend.memory_env.as_mut().expect("memory").scope_grants = + Some(MemoryScopeGrantRegistry::default()); + backend + .recover_worker(recovery_request) + .await + .expect("completed ACP recovery with fresh grant registry"); + assert_eq!( + acp_test_finished(&mut backend).await.outcome, + WorkerOutcomeKind::Succeeded + ); + assert_eq!( + fs::read_to_string(ensured.handle.workspace_path().join("acp-prompts.jsonl")) + .expect("prompts"), + prompts + ); + acp::retire(&manager, &ensured.handle, backend.acp_host.as_ref()) + .await + .expect("retire"); + } + struct AbortNotifier(Option>); impl Drop for AbortNotifier { diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs b/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs new file mode 100644 index 000000000..cea6458b0 --- /dev/null +++ b/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs @@ -0,0 +1,941 @@ +//! Production worker wiring. ACP wire types and protocol ownership stay in opensymphony_acp. +use super::*; +use crate::opensymphony_acp::{ + ClientLimits, HostServices, LaunchContext, RetentionPolicy, RuntimeProjection, SessionControl, + SessionEvent, SessionHandle, SessionHost, SessionLaunch, +}; +use crate::opensymphony_workspace::{AcpProcessState, AcpSessionIdentity, AcpSessionStatus}; +use tokio_util::sync::CancellationToken; + +pub(super) const KIND: &str = "acp"; +#[derive(Clone)] +pub(super) struct ActiveSession { + pub handle: SessionHandle, + pub cancellation: CancellationToken, + pub prompt_finished: CancellationToken, + pub workspace: WorkspaceHandle, + pub issue_id: IssueId, + pub issue_identifier: IssueIdentifier, + pub run_id: String, +} +pub(super) type ActiveSessions = Arc>>; + +pub(super) enum StopObservation { + Pending, + Stopped(Option), + Uncertain, +} + +/// A pre-submission cancellation can close the owner after it has persisted a +/// stopped process. In that case the live control channel is gone, so use only +/// the durable record for this exact owner and generation as stop evidence. +pub(super) async fn observe_stop( + session: &ActiveSession, + manager: &WorkspaceManager, +) -> Result { + match session.handle.inspect().await { + Ok(snapshot) => { + if snapshot.state.status == AcpSessionStatus::Uncertain { + return Ok(StopObservation::Uncertain); + } + if (snapshot.state.status == AcpSessionStatus::Finished + && snapshot.state.identity.run_id == session.run_id) + || (session.prompt_finished.is_cancelled() + && snapshot.retirement_eligible + && matches!( + snapshot.state.status, + AcpSessionStatus::Ready | AcpSessionStatus::Finished + )) + { + return Ok(StopObservation::Stopped(snapshot.state.stop_reason)); + } + Ok(StopObservation::Pending) + } + Err(crate::opensymphony_acp::HostError::Unavailable) => { + let manifest = manager + .load_conversation_manifest(&session.workspace) + .await + .map_err(|error| CliWorkerError::InterruptFailed(error.to_string()))? + .ok_or_else(|| { + CliWorkerError::InterruptFailed("ACP durable stop evidence is missing".into()) + })?; + let state = manifest.acp.ok_or_else(|| { + CliWorkerError::InterruptFailed("ACP durable stop evidence is missing".into()) + })?; + if manifest.issue_id != session.workspace.issue_id() + || state.owner_id != session.handle.owner_id + || state.identity.generation != session.handle.generation + || (matches!( + state.status, + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain + ) && state.identity.run_id != session.run_id) + || state.identity.workspace_path != session.workspace.workspace_path() + { + return Err(CliWorkerError::InterruptFailed( + "ACP durable stop identity mismatch".into(), + )); + } + if state.process != AcpProcessState::Stopped { + return Ok(StopObservation::Pending); + } + match state.status { + AcpSessionStatus::Ready | AcpSessionStatus::Finished => { + Ok(StopObservation::Stopped(state.stop_reason)) + } + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain => { + Ok(StopObservation::Uncertain) + } + } + } + Err(error) => Err(CliWorkerError::InterruptFailed(error.to_string())), + } +} + +pub(super) fn new_host() -> SessionHost { + SessionHost::new(RetentionPolicy { + max_sessions: 128, + ..RetentionPolicy::default() + }) + .expect("valid production ACP retention policy") +} + +pub(super) fn production_client_limits() -> ClientLimits { + // Scheduler stall detection and abort own production turn liveness. + ClientLimits { + prompt_timeout: Duration::ZERO, + ..ClientLimits::default() + } +} + +/// Normalize shared manifest fields for existing scheduler recovery. Never persist this view: +/// the ACP owner is the only writer of its durable conversation record. +pub(super) fn conversation_view(raw: &str) -> Result { + let mut value: serde_json::Value = serde_json::from_str(raw)?; + if value.get("acp").is_some_and(|state| !state.is_null()) { + let created = value["created_at"].clone(); + value["updated_at"] = created.clone(); + if value["last_attached_at"].is_null() { + value["last_attached_at"] = created; + } + value["transport_target"] = serde_json::json!(KIND); + value["server_base_url"] = serde_json::Value::Null; + value["last_execution_status"] = value["acp"]["status"].clone(); + value["workflow_prompt_seeded"] = serde_json::json!( + value + .pointer("/acp/workflow_prompt_seeded") + .and_then(serde_json::Value::as_bool) + .unwrap_or_else(|| value + .pointer("/acp/status") + .and_then(serde_json::Value::as_str) + != Some("ready") + && value + .pointer("/acp/stop_reason") + .and_then(serde_json::Value::as_str) + != Some("cancelled_before_prompt")) + ); + if value["conversation_id"].as_str() == Some("") { + // A launch reservation can precede the peer session ID. This identifies the + // owner for scheduler fencing, and is never submitted as an ACP session ID. + value["conversation_id"] = value["acp"]["owner_id"].clone(); + } + } + serde_json::from_value(value) +} + +/// Bind the current run to its ACP session without importing an older owner's +/// hierarchy or checkout snapshot from a retained conversation manifest. +pub(super) fn bind_current_run_envelopes( + run: &mut RunManifest, + conversation: &crate::opensymphony_workspace::ConversationManifest, +) { + let Some(state) = conversation.acp.as_ref() else { + return; + }; + if state.identity.run_id != run.run_id || state.identity.attempt != run.attempt { + return; + } + if let Some(envelope) = run.runtime_envelope.as_mut() { + envelope.acp_session = Some(state.identity.clone()); + envelope.conversation_binding = state.session_id.clone(); + } + if let Some(envelope) = run.parent_runtime_envelope.as_mut() { + envelope.conversation_binding = state.session_id.clone(); + } +} + +pub(super) async fn retire( + manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + host: Option<&SessionHost>, +) -> Result<(), String> { + let Some(manifest) = manager + .load_conversation_manifest(workspace) + .await + .map_err(|e| e.to_string())? + else { + return Ok(()); + }; + let Some(state) = manifest.acp else { + return Ok(()); + }; + if state.identity.workspace_path != workspace.workspace_path() + || manifest.issue_id != workspace.issue_id() + || state.identity.checkout_generation.as_deref() != workspace.checkout_generation() + { + return Err("ACP cleanup identity does not match the bound workspace".into()); + } + if let Some(host) = host { + match host + .lookup(state.owner_id.clone(), state.identity.generation) + .await + { + Ok(handle) => { + handle + .control(SessionControl::Retire) + .await + .map_err(|e| e.to_string())?; + return Ok(()); + } + Err(crate::opensymphony_acp::HostError::Unavailable) => {} + Err(error) => return Err(error.to_string()), + } + } + crate::opensymphony_acp::retire_persisted_session(manager.clone(), workspace.clone()) + .await + .map_err(|error| error.to_string()) +} + +pub(super) async fn retire_and_archive( + manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + host: &SessionHost, + preserve_manifest: bool, +) -> Result<(), String> { + retire(manager, workspace, Some(host)).await?; + let manifest = manager + .load_conversation_manifest(workspace) + .await + .map_err(|e| e.to_string())? + .ok_or("ACP conversation missing")?; + let state = manifest.acp.as_ref().ok_or("ACP state missing")?; + use sha2::{Digest, Sha256}; + let owner = format!("{:x}", Sha256::digest(state.owner_id.as_bytes())); + let path = workspace.metadata_dir().join(format!( + "acp-retired-{owner}-{}.json", + state.identity.generation + )); + manager + .write_json_artifact_atomically(workspace, &path, &manifest) + .await + .map_err(|e| e.to_string())?; + if preserve_manifest { + return Ok(()); + } + let path = manager + .validate_workspace_owned_path(workspace, &workspace.conversation_manifest_path()) + .await + .map_err(|e| e.to_string())?; + fs::remove_file(path).await.map_err(|e| e.to_string()) +} + +pub(super) async fn interrupt( + active: &ActiveSessions, + manager: &WorkspaceManager, + command: &crate::opensymphony_domain::HarnessInterruptCommand, +) -> Result { + // Scheduler interrupt commands carry the issue identifier in `run_id`, + // whereas active ACP sessions are keyed by generated worker IDs. + let session = { + let active = active.lock().map_err(|_| { + CliWorkerError::InterruptFailed("ACP active-session registry unavailable".into()) + })?; + let mut matching = active.values().filter(|session| { + session.issue_id == command.issue_id + && session.issue_identifier.as_str() == command.run_id + }); + let session = matching.next().cloned().ok_or_else(|| { + CliWorkerError::InterruptFailed("ACP worker has no active session".into()) + })?; + if matching.next().is_some() { + return Err(CliWorkerError::InterruptFailed( + "ACP interrupt matches multiple active sessions".into(), + )); + } + session + }; + let snapshot = session + .handle + .inspect() + .await + .map_err(|e| CliWorkerError::InterruptFailed(e.to_string()))?; + if snapshot.state.session_id.as_deref() != Some(command.conversation_id.as_str()) + || (matches!( + snapshot.state.status, + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain + ) && snapshot.state.identity.run_id != session.run_id) + || snapshot.state.owner_id != session.handle.owner_id + || snapshot.state.identity.generation != session.handle.generation + { + return Err(CliWorkerError::InterruptFailed( + "ACP interrupt session identity mismatch".into(), + )); + } + session.cancellation.cancel(); + let stopped = timeout(Duration::from_secs(15), async { + loop { + match observe_stop(&session, manager).await? { + StopObservation::Stopped(reason) => return Ok(reason), + StopObservation::Uncertain => { + return Err(CliWorkerError::InterruptFailed( + "ACP cancellation outcome is uncertain; cleanup remains fenced".into(), + )); + } + StopObservation::Pending => tokio::time::sleep(Duration::from_millis(20)).await, + } + } + }) + .await; + match stopped { + Ok(Ok(reason)) => Ok(WorkerInterruptAcknowledgement { + accepted: true, + detail: Some(format!( + "ACP prompt stopped: {}", + reason.unwrap_or_default() + )), + timed_out: false, + }), + Ok(Err(error)) => Err(error), + Err(_) => Ok(WorkerInterruptAcknowledgement { + accepted: false, + detail: Some("ACP interrupt deadline exceeded; cleanup remains fenced".into()), + timed_out: true, + }), + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn run_issue( + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + manifest: &mut RunManifest, + issue: &NormalizedIssue, + run: &crate::opensymphony_domain::RunAttempt, + workflow: &ResolvedWorkflow, + terminal_prompt: Option<&str>, + continuation_prompt: Option<&str>, + host: &SessionHost, + active: &ActiveSessions, + updates: &mpsc::UnboundedSender, + launch: &mut Option>, + environment: BTreeMap, + excluded_environment: BTreeSet, + recovered: bool, + fresh_conversation_grants: Option<&MemoryScopeGrantRegistry>, +) -> WorkerOutcomeRecord { + let result = try_run( + route, + manager, + workspace, + manifest, + issue, + run, + workflow, + terminal_prompt, + continuation_prompt, + host, + active, + updates, + launch, + environment, + excluded_environment, + recovered, + fresh_conversation_grants, + ) + .await; + let (kind, status, stopped, detail) = match result { + Ok(reason) => match reason.as_str() { + "end_turn" | "max_tokens" | "max_turn_requests" => ( + WorkerOutcomeKind::Succeeded, + RunStatus::Succeeded, + true, + format!("ACP turn stopped: {reason}"), + ), + "cancelled" | "cancelled_before_prompt" => ( + WorkerOutcomeKind::Cancelled, + RunStatus::Cancelled, + true, + "ACP cancellation acknowledged by prompt response".into(), + ), + // Refusal and unsupported stop reasons are not eligible for automatic retry. + _ => ( + WorkerOutcomeKind::Detached, + RunStatus::Failed, + true, + format!("ACP unsuccessful stop reason: {reason}"), + ), + }, + Err(detail) => { + let loaded = manager.load_conversation_manifest(workspace).await; + let unreadable = loaded.is_err(); + let state = loaded.ok().flatten().and_then(|m| m.acp); + let uncertain = unreadable + || (state.is_none() && manifest.started_at.is_some()) + || state.as_ref().is_some_and(|state| { + matches!( + state.status, + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain + ) + }); + let stopped = !uncertain + && state.as_ref().is_none_or(|state| { + state.status == AcpSessionStatus::Finished + || state.process == AcpProcessState::Stopped + }); + ( + if uncertain { + WorkerOutcomeKind::Detached + } else { + WorkerOutcomeKind::Failed + }, + RunStatus::Failed, + stopped, + detail, + ) + } + }; + if launch.is_some() { + // Expose uncertain ownership to the scheduler as a terminal fenced outcome instead + // of treating a possibly delivered prompt as a retryable launch failure. + if let Ok(Some(raw)) = manager + .read_text_artifact(workspace, &workspace.conversation_manifest_path()) + .await + && let Ok(view) = conversation_view(&raw) + { + if let Some(sender) = launch.take() { + let mut metadata = conversation_metadata_from_manifest(&view); + if let Ok(manifest) = serde_json::from_str::< + crate::opensymphony_workspace::ConversationManifest, + >(&raw) + && let Some(state) = manifest.acp + && state.initialization["protocolVersion"].as_u64() == Some(1) + { + metadata.harness_capability = + Some(Box::new(crate::opensymphony_acp::run_capability(&state))); + } + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(metadata), + started_at: manifest.started_at.map(datetime_to_timestamp_ms), + }); + } + } else { + report_launch_failure(launch, detail.clone()); + } + } + if let Ok(Some(conversation)) = manager.load_conversation_manifest(workspace).await { + bind_current_run_envelopes(manifest, &conversation); + } + manifest.harness_stopped = stopped; + manifest.status_detail = Some(detail.clone()); + manifest.status = status; + let finish = if stopped { + manager.finish_run(workspace, manifest, status).await + } else { + manager.write_run_manifest(workspace, manifest).await + }; + let mut outcome = WorkerOutcomeRecord::from_run( + run, + if finish.is_err() && kind == WorkerOutcomeKind::Succeeded { + WorkerOutcomeKind::Failed + } else { + kind + }, + now_timestamp(), + Some(detail), + finish.err().map(|e| e.to_string()), + ); + outcome.harness_stopped = stopped; + active + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(run.worker_id.as_str()); + outcome +} + +pub(super) fn launch_environment( + ambient: impl IntoIterator, + overlay: &BTreeMap, +) -> BTreeMap { + let mut environment = ambient + .into_iter() + .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))) + .filter(|(name, _)| !crate::opensymphony_acp::is_reserved_memory_environment_name(name)) + .collect::>(); + for (name, value) in overlay { + crate::opensymphony_workspace::insert_environment_value( + &mut environment, + name.clone(), + value.clone(), + ); + } + environment +} + +pub(super) struct EffectiveLaunchIdentity { + pub profile: crate::opensymphony_acp::AcpProfile, + pub services: HostServices, + pub profile_fingerprint: String, + pub credential_scope: String, +} + +pub(super) fn effective_launch_identity( + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + workflow: &ResolvedWorkflow, + worker_environment: &BTreeMap, + environment: &BTreeMap, + limits: &ClientLimits, +) -> Result { + let profile_id = route + .harness_profile + .as_ref() + .ok_or("ACP route has no profile identity")?; + let mut profile = workflow + .extensions + .acp + .profiles + .get(profile_id) + .cloned() + .ok_or("persisted ACP profile is unavailable")?; + if let Some(model) = &route.model { + profile.session.model = Some(model.clone()); + } + profile.validate().map_err(|error| error.to_string())?; + let mut services = HostServices { + read_files: true, + write_files: true, + terminals: true, + ..HostServices::default() + }; + if let Some(endpoint) = worker_environment.get("OPENSYMPHONY_MEMORY_ENDPOINT") { + services.attach_scoped_memory( + endpoint, + worker_environment + .get("OPENSYMPHONY_MEMORY_TOKEN") + .map(String::as_str) + .filter(|token| !token.is_empty()), + ); + } + let profile_fingerprint = + crate::opensymphony_acp::launch_profile_fingerprint(&profile, &services, limits)?; + let credential_scope = credential_scope( + profile.env_refs.values().map(String::as_str).chain([ + "OPENSYMPHONY_MEMORY_TOKEN", + "OPENSYMPHONY_MEMORY_ENDPOINT", + "OPENSYMPHONY_MEMORY_PROJECT", + "OPENSYMPHONY_MEMORY_PROJECT_SET", + "OPENSYMPHONY_MEMORY_EXECUTION_REPO", + "OPENSYMPHONY_MEMORY_AUTHORIZED_REPOSITORIES", + "OPENSYMPHONY_MEMORY_RUN_ID", + "OPENSYMPHONY_MEMORY_ATTEMPT", + ]), + environment, + ); + Ok(EffectiveLaunchIdentity { + profile, + services, + profile_fingerprint, + credential_scope, + }) +} + +fn credential_scope<'a>( + names: impl IntoIterator, + environment: &BTreeMap, +) -> String { + // Fingerprint resolved credential scope without persisting credentials or environment. + use sha2::{Digest, Sha256}; + let mut scope = Sha256::new(); + for name in names { + if let Some((_, value)) = environment + .iter() + .find(|(key, _)| environment_variable_names_equal(key, name)) + { + scope.update(name.as_bytes()); + scope.update([0]); + scope.update(value.as_bytes()); + scope.update([0]); + } + } + format!("{:x}", scope.finalize()) +} + +#[allow(clippy::too_many_arguments)] +async fn try_run( + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + manifest: &mut RunManifest, + issue: &NormalizedIssue, + run: &crate::opensymphony_domain::RunAttempt, + workflow: &ResolvedWorkflow, + terminal_prompt: Option<&str>, + continuation_prompt: Option<&str>, + host: &SessionHost, + active: &ActiveSessions, + updates: &mpsc::UnboundedSender, + launch: &mut Option>, + environment: BTreeMap, + excluded_environment: BTreeSet, + recovered: bool, + fresh_conversation_grants: Option<&MemoryScopeGrantRegistry>, +) -> Result { + let previous = manager + .load_conversation_manifest(workspace) + .await + .map_err(|e| e.to_string())? + .and_then(|m| m.acp); + if recovered && let Some(previous) = &previous { + if matches!( + previous.status, + AcpSessionStatus::Submitted | AcpSessionStatus::Uncertain + ) { + return Err("ACP prior submission is uncertain; refusing automatic replay".into()); + } + if previous.status == AcpSessionStatus::Finished + && previous.identity.run_id == manifest.run_id + && previous.identity.attempt == manifest.attempt + { + return Ok(previous + .stop_reason + .clone() + .unwrap_or_else(|| "unsupported".into())); + } + } + let worker_environment = environment; + let environment = launch_environment(env::vars_os(), &worker_environment); + let limits = production_client_limits(); + let EffectiveLaunchIdentity { + profile, + services, + profile_fingerprint, + credential_scope, + } = effective_launch_identity(route, workflow, &worker_environment, &environment, &limits)?; + let profile_id = route + .harness_profile + .as_ref() + .ok_or("ACP route has no profile identity")?; + manager + .write_json_artifact_atomically( + workspace, + &workspace.metadata_dir().join("harness-route.json"), + route, + ) + .await + .map_err(|e| e.to_string())?; + let memory_prompt = memory_scope_prompt_from_environment(&worker_environment); + let handle = host + .open(SessionLaunch { + manager: manager.clone(), + workspace: workspace.clone(), + identity: AcpSessionIdentity { + profile_id: profile_id.clone(), + profile_fingerprint, + credential_scope, + workspace_path: workspace.workspace_path().to_path_buf(), + repository_binding: run.repository_binding.clone(), + checkout_generation: workspace.checkout_generation().map(str::to_owned), + generation: 0, + run_id: manifest.run_id.clone(), + attempt: manifest.attempt, + }, + profile, + context: LaunchContext { + // The verified handle may be a generation directory or nested parent + // workspace. Narrow the client boundary to its immediate parent; + // the owner separately validates the full manager-owned identity. + workspace_root: workspace + .workspace_path() + .parent() + .ok_or("ACP workspace has no parent")? + .to_path_buf(), + workspace_key: workspace + .workspace_path() + .file_name() + .and_then(|name| name.to_str()) + .ok_or("ACP workspace directory is invalid")? + .to_owned(), + issue_workspace: workspace.workspace_path().to_path_buf(), + environment, + excluded_environment, + services, + }, + limits, + require_persistence: manifest.parent_runtime_envelope.is_some(), + expected_session_id: manifest + .parent_runtime_envelope + .as_ref() + .and_then(|envelope| envelope.conversation_binding.clone()), + }) + .await + .map_err(|e| e.to_string())?; + let snapshot = handle.inspect().await.map_err(|e| e.to_string())?; + // A restored session can still be Ready when no prompt was ever submitted. + // Recovery kind describes the peer session, not whether workflow context + // has been seeded. Keep this separate from fresh-owner grant accounting. + let needs_full_prompt = !snapshot.state.workflow_prompt_seeded(); + let fresh = snapshot.state.recovery == crate::opensymphony_workspace::AcpRecovery::Fresh + && snapshot.state.status == AcpSessionStatus::Ready; + let mut prompt = if needs_full_prompt { + terminal_prompt + .map(str::to_owned) + .map(Ok) + .unwrap_or_else(|| { + workflow + .render_prompt(issue, run.attempt.map(|a| a.get())) + .map_err(|e| e.to_string()) + })? + } else { + continuation_prompt.unwrap_or("Continue the current issue from its retained workspace and conversation. Reconcile current tracker status and finish the remaining authorized work.").to_owned() + }; + if let Some(scope) = memory_prompt { + prompt.push_str(&scope); + } + manifest.status = RunStatus::Running; + manifest.started_at = Some(chrono::Utc::now()); + if let Some(envelope) = manifest.runtime_envelope.as_mut() { + envelope.acp_session = Some(snapshot.state.identity.clone()); + envelope.conversation_binding = snapshot.state.session_id.clone(); + } + if let Some(envelope) = manifest.parent_runtime_envelope.as_mut() { + envelope.conversation_binding = snapshot.state.session_id.clone(); + } + manager + .write_run_manifest(workspace, manifest) + .await + .map_err(|e| e.to_string())?; + let raw = manager + .read_text_artifact(workspace, &workspace.conversation_manifest_path()) + .await + .map_err(|e| e.to_string())? + .ok_or("ACP manifest is missing after launch")?; + let view = conversation_view(&raw).map_err(|e| e.to_string())?; + let mut metadata = conversation_metadata_from_manifest(&view); + metadata.stream_state = RuntimeStreamState::Ready; + let mut capability = crate::opensymphony_acp::run_capability(&snapshot.state); + metadata.harness_capability = Some(Box::new(capability.clone())); + let cancellation = CancellationToken::new(); + let _cancel_on_drop = cancellation.clone().drop_guard(); + let prompt_finished = CancellationToken::new(); + let _finish_on_drop = prompt_finished.clone().drop_guard(); + let active_session = ActiveSession { + handle: handle.clone(), + cancellation: cancellation.clone(), + prompt_finished: prompt_finished.clone(), + workspace: workspace.clone(), + issue_id: issue.id.clone(), + issue_identifier: issue.identifier.clone(), + run_id: manifest.run_id.clone(), + }; + active + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(run.worker_id.to_string(), active_session.clone()); + if let Some(sender) = launch.take() { + let reported = sender.send(LaunchReport::Conversation { + conversation: Box::new(metadata), + started_at: manifest.started_at.map(datetime_to_timestamp_ms), + }); + if fresh + && reported.is_ok() + && let Some(grants) = fresh_conversation_grants + { + grants.acknowledge_fresh_conversation(issue.identifier.as_str()); + } + } + let mut receiver = handle.subscribe(); + let baseline = handle + .source_history() + .latest_cursor + .unwrap_or((handle.generation, 0)); + let mut projection = RuntimeProjection::after(baseline); + let prompt = handle.prompt( + manifest.run_id.clone(), + manifest.attempt, + prompt, + cancellation.clone(), + ); + tokio::pin!(prompt); + let result = loop { + tokio::select! { + result = &mut prompt => break result, + event = receiver.recv() => match event { + Ok(SessionEvent::Gap { .. }) => return Err("ACP source stream lost an oversized frame; submission remains fenced".into()), + Ok(event) => project_event(&event, &mut projection, &mut capability, &manifest.run_id, &run.worker_id, updates), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + let history = handle.source_history(); + if !history.covers_since(projection.last_cursor().unwrap_or(baseline)) { return Err("ACP worker source stream exceeded retention; submission remains fenced".into()); } + for event in history.events { project_event(&event, &mut projection, &mut capability, &manifest.run_id, &run.worker_id, updates); } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break Err(crate::opensymphony_acp::HostError::Unavailable), + } + } + }; + // The owner's prompt response follows earlier updates; drain before publishing Finished. + loop { + match receiver.try_recv() { + Ok(SessionEvent::Gap { .. }) => { + return Err("ACP source stream lost an oversized frame".into()); + } + Ok(event) => project_event( + &event, + &mut projection, + &mut capability, + &manifest.run_id, + &run.worker_id, + updates, + ), + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => { + let history = handle.source_history(); + if !history.covers_since(projection.last_cursor().unwrap_or(baseline)) { + return Err("ACP worker source stream exceeded retention".into()); + } + for event in history.events { + project_event( + &event, + &mut projection, + &mut capability, + &manifest.run_id, + &run.worker_id, + updates, + ); + } + } + Err(_) => break, + } + } + prompt_finished.cancel(); + if result.is_err() && cancellation.is_cancelled() { + let stopped = timeout(Duration::from_secs(15), async { + loop { + match observe_stop(&active_session, manager).await { + Ok(StopObservation::Stopped(_)) => return true, + Ok(StopObservation::Pending) => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + Ok(StopObservation::Uncertain) | Err(_) => return false, + } + } + }) + .await + .unwrap_or(false); + if stopped { + return Ok("cancelled".into()); + } + } + result + .map(|report| report.stop_reason) + .map_err(|e| e.to_string()) +} + +fn project_event( + event: &SessionEvent, + projection: &mut RuntimeProjection, + capability: &mut crate::opensymphony_gateway_schema::capability::HarnessRunCapability, + run_id: &str, + worker_id: &crate::opensymphony_domain::WorkerId, + updates: &mpsc::UnboundedSender, +) { + if let SessionEvent::State { snapshot } = event + && snapshot.state.identity.run_id == run_id + { + let negotiated = crate::opensymphony_acp::run_capability(&snapshot.state); + if *capability != negotiated { + *capability = negotiated.clone(); + let _ = updates.send(WorkerUpdate::HarnessCapabilityUpdate { + worker_id: worker_id.clone(), + capability: negotiated, + }); + } + } + if let Some(update) = projection.apply(event, run_id) { + let payload = (!update.payload.is_null()).then_some(update.payload); + let _ = updates.send(WorkerUpdate::RuntimeEvent { + worker_id: worker_id.clone(), + observed_at: datetime_to_timestamp_ms(update.observed_at), + event_id: Some(format!("acp-{}-{}", update.generation, update.sequence)), + event_kind: Some(format!("acp.{}", update.kind)), + summary: update.summary, + payload, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::os::unix::ffi::OsStringExt; + + #[test] + fn credential_scope_uses_platform_environment_name_rules() { + let first = BTreeMap::from([("AGENT_TOKEN".into(), "first".into())]); + let changed = BTreeMap::from([("AGENT_TOKEN".into(), "second".into())]); + let source = ["agent_token"]; + assert_eq!( + credential_scope(source, &first) != credential_scope(source, &changed), + cfg!(windows), + "only Windows resolves the differently cased credential reference" + ); + assert_ne!( + credential_scope(["AGENT_TOKEN"], &first), + credential_scope(["AGENT_TOKEN"], &changed), + ); + } + + #[cfg(unix)] + #[test] + fn launch_environment_skips_unrelated_non_utf8_ambient_values() { + let ambient = [ + ("GOOD".into(), "ambient".into()), + ("BAD".into(), std::ffi::OsString::from_vec(vec![0xff])), + (std::ffi::OsString::from_vec(vec![0xfe]), "value".into()), + ]; + let overlay = BTreeMap::from([ + ("GOOD".into(), "worker".into()), + ("SCOPED".into(), "token".into()), + ]); + let environment = launch_environment(ambient, &overlay); + assert_eq!(environment.get("GOOD").map(String::as_str), Some("worker")); + assert_eq!(environment.get("SCOPED").map(String::as_str), Some("token")); + assert!(!environment.contains_key("BAD")); + assert_eq!(environment.len(), 2); + } + + #[test] + fn launch_environment_replaces_ambient_memory_scope_with_worker_grant() { + let ambient = [ + ( + "OPENSYMPHONY_MEMORY_ADMIN_TOKEN".into(), + "admin-bearer".into(), + ), + ("OPENSYMPHONY_MEMORY_TOKEN".into(), "stale-grant".into()), + ("OPENSYMPHONY_MEMORY_PROJECT".into(), "stale-project".into()), + ("PATH".into(), "/usr/bin".into()), + ]; + let overlay = BTreeMap::from([ + ("OPENSYMPHONY_MEMORY_TOKEN".into(), "scoped-grant".into()), + ( + "OPENSYMPHONY_MEMORY_PROJECT".into(), + "current-project".into(), + ), + ]); + let environment = launch_environment(ambient, &overlay); + assert!(!environment.contains_key("OPENSYMPHONY_MEMORY_ADMIN_TOKEN")); + assert_eq!(environment["OPENSYMPHONY_MEMORY_TOKEN"], "scoped-grant"); + assert_eq!( + environment["OPENSYMPHONY_MEMORY_PROJECT"], + "current-project" + ); + assert_eq!(environment["PATH"], "/usr/bin"); + } +} diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index ec8fa42ee..8650b6a96 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -2964,8 +2964,16 @@ scheduler: .as_deref(), Some("local") ); - let local = WorkflowFrontMatter::default(); + let mut local = WorkflowFrontMatter::default(); + local.routing.harness = Some("codex_app_server".into()); + local.routing.model = Some("repository-model".into()); + let mut local_profile = resolved.workflow_front_matter.acp.profiles["local"].clone(); + local_profile.command = "repository-command".into(); + local.acp.profiles.insert("local".into(), local_profile); let merged = merge_repository_local_front_matter(resolved.workflow_front_matter, &local); + assert_eq!(merged.routing.harness.as_deref(), Some("acp")); + assert!(merged.routing.model.is_none()); + assert_eq!(merged.acp.profiles["local"].command, "python3"); assert_eq!( merged.acp.profiles["local"].env_refs["AGENT_TOKEN"], "ACP_TEST_TOKEN" @@ -3040,13 +3048,21 @@ scheduler: .harness, "acp" ); - overrides.insert("TEST_MODEL".into(), "unsupported-model".into()); + overrides.insert("TEST_MODEL".into(), "selected-model".into()); + let selected = workflow + .resolve(repo.path(), &overrides) + .expect("ACP model selection is validated against the peer at launch") + .config + .routing; + assert_eq!(selected.model.as_deref(), Some("selected-model")); + assert!(selected.model_from_env); + overrides.insert("TEST_MODEL_PROFILE".into(), "openhands-profile".into()); assert!( workflow .resolve(repo.path(), &overrides) - .expect_err("resolved model override") + .expect_err("OpenHands model profiles have no ACP mapping") .to_string() - .contains("ACP model overrides") + .contains("routing.model_profile is not supported for ACP") ); } } diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index aadf01e19..2b1c95448 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -33,12 +33,11 @@ use crate::opensymphony_gateway::{GatewayServer, LinearTaskGraphClient}; use crate::opensymphony_gateway_schema::event_journal::{EventActor, EventKind, EventRecord}; use crate::opensymphony_linear::LinearError; use crate::opensymphony_memory::MemoryVisibility; -use crate::opensymphony_openhands::{OpenHandsError, TransportConfig}; +use crate::opensymphony_openhands::OpenHandsError; use crate::opensymphony_orchestrator::{ IssueStateCategory, OrchestratorSnapshot, Scheduler, SchedulerConfig, SchedulerError, TrackerBackend, WorkerBackend, WorkspaceBackend, }; -use crate::opensymphony_workflow::ProcessEnvironment; use crate::opensymphony_workspace::{WorkspaceError, checkout_credential_environment_variables}; use chrono::{DateTime, Utc}; use clap::Args; @@ -138,6 +137,8 @@ pub(crate) enum RunCommandError { Tracker(#[from] LinearError), #[error("failed to create workspace manager: {0}")] WorkspaceManager(#[from] WorkspaceError), + #[error("failed to prepare ACP session host: {0}")] + AcpHost(#[from] crate::opensymphony_acp::HostError), #[error("failed to prepare OpenHands transport: {0}")] Transport(#[from] OpenHandsError), #[error("failed to prepare OpenHands conversation store: {0}")] @@ -1025,14 +1026,21 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { .root .join(".opensymphony-retry-state") }); + let acp_host = + crate::opensymphony_acp::SessionHost::new(crate::opensymphony_acp::RetentionPolicy { + max_sessions: 128, + ..crate::opensymphony_acp::RetentionPolicy::default() + })?; let mut workspace = RuntimeWorkspaceBackend::new_with_retention_and_state_root( workspace_manager.clone(), &runtime.workflow, runtime.retain_failed, retry_state_root, ) + .with_acp_host(acp_host.clone()) .with_openhands_conversation_store(runtime.openhands_conversation_store.clone()); - let selected_openhands = selected_openhands_harness(&runtime); + let selected_openhands = selected_openhands_harness(&runtime) + || backends::requires_openhands_recovery(workspace_manager.as_ref()).await?; let managed_local_preparation = if selected_openhands { prepare_active_conversation_store(&runtime, &mut tracker, workspace_manager.as_ref()) .await? @@ -1112,25 +1120,30 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { info!(endpoint = %env.endpoint, "started OpenSymphony memory server"); } - let (transport, mut supervisor) = if selected_openhands { - build_runtime_transport( + let (client, mut supervisor) = if selected_openhands { + let (transport, supervisor) = build_runtime_transport( &runtime, managed_local_preparation.tooling, &linear_worker_env, ) - .await? + .await?; + let client = crate::opensymphony_openhands::OpenHandsClient::new(transport); + client.openapi_probe().await?; + (Some(client), supervisor) } else { - ( - TransportConfig::from_workflow(&runtime.workflow, &ProcessEnvironment)?, - None, - ) + (None, None) }; - let client = crate::opensymphony_openhands::OpenHandsClient::new(transport); - if selected_openhands { - client.openapi_probe().await?; - } + let agent_server_base_url = client + .as_ref() + .map(|client| client.base_url()) + .unwrap_or(""); + let acp_profiles = crate::opensymphony_acp::profile_capabilities( + &runtime.workflow.extensions.acp, + &linear_worker_env, + &backends::runtime_checkout_credential_envs(&runtime), + ); - let worker = RuntimeWorkerBackend::new( + let worker = RuntimeWorkerBackend::new_with_client( client.clone(), Arc::new(runtime.workflow.clone()), workspace_manager, @@ -1138,6 +1151,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { linear_worker_env, ) .with_openhands_conversation_store(runtime.openhands_conversation_store.clone()) + .with_acp_host(acp_host) .with_integration_instructions(runtime.integration_instructions.clone()) .with_checkout_credential_envs( runtime @@ -1177,7 +1191,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { &scheduler.snapshot(now_timestamp()), runtime.workflow.config.workspace.root.as_path(), &terminal_state_set(&runtime.workflow), - current_agent_server_status(&mut supervisor, client.base_url()), + current_agent_server_status(&mut supervisor, agent_server_base_url), current_memory_server_status(memory_server.as_ref()), &recent_events, ); @@ -1202,6 +1216,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { GatewayServer::with_journal(store.clone(), gateway_journal.clone(), gateway_broker) .with_linear_task_graph(build_optional_task_graph_client(&runtime.workflow)) .with_memory_config(server_memory_config) + .with_harness_profiles(acp_profiles) .with_active_states( runtime .workflow @@ -1276,7 +1291,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { &bootstrap_snapshot, runtime.workflow.config.workspace.root.as_path(), &terminal_state_set(&runtime.workflow), - current_agent_server_status(&mut supervisor, client.base_url()), + current_agent_server_status(&mut supervisor, agent_server_base_url), current_memory_server_status(memory_server.as_ref()), &recent_events, )) @@ -1343,7 +1358,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { &snapshot, runtime.workflow.config.workspace.root.as_path(), &terminal_state_set(&runtime.workflow), - current_agent_server_status(&mut supervisor, client.base_url()), + current_agent_server_status(&mut supervisor, agent_server_base_url), current_memory_server_status(memory_server.as_ref()), &recent_events, )).await; @@ -1410,7 +1425,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { SnapshotPublishContext { runtime: &runtime, supervisor: &mut supervisor, - agent_server_base_url: client.base_url(), + agent_server_base_url, memory_server: memory_server.as_ref(), memory_config: gateway_memory_config.as_ref(), recent_events: &mut recent_events, @@ -1433,7 +1448,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { &snapshot, runtime.workflow.config.workspace.root.as_path(), &terminal_state_set(&runtime.workflow), - current_agent_server_status(&mut supervisor, client.base_url()), + current_agent_server_status(&mut supervisor, agent_server_base_url), current_memory_server_status(memory_server.as_ref()), &recent_events, )).await; diff --git a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs index 803fc5c62..edc53dcaf 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs @@ -118,7 +118,9 @@ fn map_issue( Some( WorkerOutcomeKind::Failed | WorkerOutcomeKind::TimedOut - | WorkerOutcomeKind::Stalled, + | WorkerOutcomeKind::Stalled + | WorkerOutcomeKind::Detached + | WorkerOutcomeKind::CancelFailed, ) => IssueRuntimeState::Failed, _ if issue.runtime.release_reason == Some(crate::opensymphony_domain::ReleaseReason::RetryExhausted) => @@ -241,6 +243,10 @@ fn map_issue( }); IssueSnapshot { + harness_capability: issue + .conversation + .as_ref() + .and_then(|c| c.harness_capability.as_deref().cloned()), identifier: issue.issue.identifier.to_string(), title: issue.issue.title.clone(), tracker_state: issue.issue.state.name.clone(), @@ -514,10 +520,15 @@ pub(super) fn current_agent_server_status( } AgentServerStatus { - reachable: true, + reachable: !base_url.is_empty(), base_url: base_url.to_string(), conversation_count: 0, - status_line: "reachable".to_string(), + status_line: if base_url.is_empty() { + "not_selected" + } else { + "reachable" + } + .to_string(), } } @@ -704,6 +715,7 @@ tracker: last_seen_tracker_refresh_at: None, }), conversation: Some(ConversationMetadata { + harness_capability: None, conversation_id: must(ConversationId::new("conv_352")), server_base_url: Some("http://127.0.0.1:3000".to_owned()), transport_target: Some("loopback".to_owned()), @@ -901,6 +913,7 @@ tracker: fn codex_conversation(thread_id: &str) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: must(ConversationId::new(thread_id.to_owned())), server_base_url: None, transport_target: Some("codex_app_server".to_owned()), @@ -1088,6 +1101,36 @@ tracker: ); } + #[test] + fn detached_active_run_is_failed_while_its_cleanup_remains_fenced() { + let mut issue = released_issue_snapshot( + "In Progress", + IssueStateCategory::Active, + crate::opensymphony_domain::ReleaseReason::Completed, + ); + issue.last_worker_outcome = Some(WorkerOutcomeRecord { + worker_id: must(WorkerId::new("worker-acp-uncertain")), + attempt: None, + outcome: WorkerOutcomeKind::Detached, + started_at: ts(1_000), + finished_at: ts(1_400), + turn_count: 1, + summary: Some("ACP prompt outcome is uncertain".into()), + error: None, + harness_stopped: false, + parent_verification: None, + }); + let issue = map_single_issue(issue); + assert_eq!( + issue.runtime_state, + crate::opensymphony_control::IssueRuntimeState::Failed + ); + assert_eq!( + issue.last_outcome, + crate::opensymphony_control::WorkerOutcome::Failed + ); + } + #[test] fn terminal_tracker_state_overrides_a_failed_worker_outcome() { let mut issue = released_issue_snapshot( diff --git a/crates/opensymphony-cli/tests/run.rs b/crates/opensymphony-cli/tests/run.rs index 2fb4405de..9d1f5be43 100644 --- a/crates/opensymphony-cli/tests/run.rs +++ b/crates/opensymphony-cli/tests/run.rs @@ -20,6 +20,158 @@ use tokio::{ time::{Instant, sleep}, }; +#[tokio::test] +async fn run_dispatches_acp_with_exact_cwd_hooks_and_no_openhands() { + let linear = MockLinearGraphqlServer::start_with_active_issue().await; + let project = TempDir::new().expect("project"); + let bind = reserve_socket_addr(); + let peer = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/acp_worker_peer.py"); + write_project_files_with_workflow_extra( + project.path(), + linear.base_url(), + "http://127.0.0.1:9", + format!("control_plane:\n bind: {bind}\nmemory:\n auto_capture: false\n serve: true\n"), + &format!( + "routing:\n harness: acp\n harness_profile: first\nacp:\n profiles:\n first:\n command: python3\n args: ['{}', first]\n second:\n command: python3\n args: ['{}', second]\npolling:\n interval_ms: 50\nhooks:\n after_create: 'echo after_create >> hooks'\n before_run: 'echo before_run >> hooks'\n after_run: 'echo after_run >> hooks'\n", + peer.display(), + peer.display() + ), + ); + write_memory_config(project.path()); + let mut child = spawn_run_child(project.path(), &[]); + let workspace = project.path().join("var/workspaces/COE-429"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if std::fs::read_to_string(workspace.join(".opensymphony/run.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .is_some_and(|run| run["status"] == "succeeded") + && std::fs::read_to_string(workspace.join("hooks")) + .is_ok_and(|hooks| hooks.contains("after_run\n")) + { + break; + } + if let Some(status) = child.try_wait().expect("child status") { + use tokio::io::AsyncReadExt; + let mut error = String::new(); + if let Some(mut stderr) = child.stderr.take() { + let _ = stderr.read_to_string(&mut error).await; + } + panic!("ACP CLI exited {status}: {error}"); + } + if Instant::now() >= deadline { + terminate_child(&mut child).await; + panic!( + "ACP CLI did not finish: {:?}", + std::fs::read_to_string(workspace.join(".opensymphony/run.json")) + ); + } + sleep(Duration::from_millis(25)).await; + } + let evidence: Value = serde_json::from_str( + &std::fs::read_to_string(workspace.join("acp-worker.json")).expect("peer evidence"), + ) + .expect("json"); + assert_eq!( + std::path::Path::new(evidence["cwd"].as_str().expect("cwd")), + std::fs::canonicalize(&workspace).expect("workspace") + ); + assert_eq!(evidence["profile"], "first"); + assert_eq!(evidence["memory_project"], "project-test"); + assert_eq!(evidence["memory_token_present"], true); + let first_prompt: Value = serde_json::from_str( + std::fs::read_to_string(workspace.join("acp-prompts.jsonl")) + .expect("prompt history") + .lines() + .next() + .expect("first prompt"), + ) + .expect("prompt record"); + assert!( + first_prompt["prompt"] + .as_str() + .expect("prompt") + .contains("Run the scheduler") + ); + let hooks = std::fs::read_to_string(workspace.join("hooks")).expect("hooks"); + assert!(hooks.starts_with("after_create\nbefore_run\n")); + assert!(hooks.contains("after_run\n")); + let route: Value = serde_json::from_str( + &std::fs::read_to_string(workspace.join(".opensymphony/harness-route.json")) + .expect("route"), + ) + .expect("json"); + assert_eq!(route["harness_profile"], "first"); + let snapshot = loop { + let snapshot: Value = reqwest::get(format!("http://{bind}/api/v1/snapshot")) + .await + .expect("snapshot") + .json() + .await + .expect("json"); + if snapshot["snapshot"]["issues"][0]["recent_events"] + .as_array() + .is_some_and(|events| events.iter().any(|event| event["kind"] == "acp.turn_usage")) + { + break snapshot; + } + assert!( + Instant::now() < deadline, + "ACP usage did not reach public snapshot: {snapshot}" + ); + sleep(Duration::from_millis(25)).await; + }; + let events = snapshot["snapshot"]["issues"][0]["recent_events"] + .as_array() + .expect("events"); + let usage = events + .iter() + .find(|event| event["kind"] == "acp.turn_usage") + .expect("turn usage"); + assert_eq!(usage["payload"]["inputTokens"], 4); + assert!(usage["payload"].get("cachedReadTokens").is_none()); + assert!(events.iter().any(|event| event["kind"] == "acp.plan")); + let context_usage = events + .iter() + .find(|event| event["kind"] == "acp.usage_update") + .expect("context usage"); + assert_eq!(context_usage["payload"]["used"], 42); + assert!(context_usage["payload"].get("inputTokens").is_none()); + assert!(snapshot.to_string().contains("acp")); + assert!(snapshot["snapshot"]["issues"][0]["server_base_url"].is_null()); + assert_eq!( + snapshot["snapshot"]["agent_server"]["status_line"], + "not_selected" + ); + let capabilities: Value = reqwest::get(format!("http://{bind}/api/v1/capabilities")) + .await + .expect("capabilities") + .json() + .await + .expect("json"); + assert!( + capabilities["harnesses"] + .as_array() + .expect("adapters") + .iter() + .any(|c| c["kind"] == "acp" && c["available"] == true) + ); + assert_eq!( + capabilities["harness_profiles"] + .as_array() + .expect("profiles") + .len(), + 2 + ); + assert!( + capabilities["harness_profiles"][0]["preflight_ready"] + .as_bool() + .expect("ready") + ); + terminate_child(&mut child).await; +} + #[tokio::test] async fn run_auto_detects_config_and_workflow_from_project_directory() { let openhands = FakeOpenHandsServer::start() @@ -241,11 +393,26 @@ async fn run_dispatches_gateway_cancel_to_openhands_interrupt() { ); write_memory_config(project.path()); - let mut child = spawn_run_child(project.path(), &[]); + // Keep this below Linux's 2 MiB Tokio worker default to catch poll-stack growth. + let mut child = spawn_run_child_with_worker_stack(project.path(), &[], 1_572_864); + use tokio::io::AsyncReadExt; + let mut child_stderr = child.stderr.take().expect("run child stderr"); + let stderr_task = tokio::spawn(async move { + let mut stderr = String::new(); + let _ = child_stderr.read_to_string(&mut stderr).await; + stderr + }); let gateway_base = format!("http://{bind_addr}"); - wait_for_running_issue(&format!("{gateway_base}/api/v1/snapshot"), "COE-429") - .await - .expect("run command should expose a running issue before cancel"); + if let Err(error) = + wait_for_running_issue(&format!("{gateway_base}/api/v1/snapshot"), "COE-429").await + { + let status = child.try_wait().expect("run child status"); + terminate_child(&mut child).await; + let stderr = stderr_task.await.expect("run child stderr task"); + panic!( + "run command should expose a running issue before cancel: {error}; child status: {status:?}; child stderr: {stderr}" + ); + } let response = reqwest::Client::new() .post(format!("{gateway_base}/api/v1/actions/dispatch")) @@ -269,6 +436,7 @@ async fn run_dispatches_gateway_cancel_to_openhands_interrupt() { .expect("gateway cancel should reach fake OpenHands interrupt"); terminate_child(&mut child).await; + let _ = stderr_task.await; } #[test] @@ -312,6 +480,8 @@ Run the scheduler. .arg("run") .current_dir(project.path()) .env("LINEAR_API_KEY", "test-linear-key") + .env_remove("LINEAR_CLIENT_ID") + .env_remove("LINEAR_CLIENT_SECRET") .env_remove("OPENSYMPHONY_HARNESS") .env_remove("OPENSYMPHONY_MODEL") .env_remove("OPENSYMPHONY_MODEL_PROFILE") @@ -335,7 +505,15 @@ Run the scheduler. } fn spawn_run_child(project_root: &std::path::Path, extra_args: &[&str]) -> Child { - spawn_run_child_configured(project_root, extra_args, None) + spawn_run_child_configured(project_root, extra_args, None, None) +} + +fn spawn_run_child_with_worker_stack( + project_root: &std::path::Path, + extra_args: &[&str], + stack_bytes: usize, +) -> Child { + spawn_run_child_configured(project_root, extra_args, None, Some(stack_bytes)) } fn spawn_run_child_with_codex_bin( @@ -343,13 +521,14 @@ fn spawn_run_child_with_codex_bin( extra_args: &[&str], codex_bin: &str, ) -> Child { - spawn_run_child_configured(project_root, extra_args, Some(codex_bin)) + spawn_run_child_configured(project_root, extra_args, Some(codex_bin), None) } fn spawn_run_child_configured( project_root: &std::path::Path, extra_args: &[&str], codex_bin: Option<&str>, + worker_stack_bytes: Option, ) -> Child { let mut command = Command::new(env!("CARGO_BIN_EXE_opensymphony")); command @@ -370,6 +549,11 @@ fn spawn_run_child_configured( if let Some(codex_bin) = codex_bin { command.env("OPENSYMPHONY_CODEX_BIN", codex_bin); } + if cfg!(unix) + && let Some(stack_bytes) = worker_stack_bytes + { + command.env("RUST_MIN_STACK", stack_bytes.to_string()); + } command.spawn().expect("run command should spawn") } @@ -633,17 +817,43 @@ async fn wait_for_dry_run_route_decision(url: &str) -> Result<(), String> { async fn wait_for_running_issue(url: &str, identifier: &str) -> Result<(), String> { let client = reqwest::Client::new(); let deadline = Instant::now() + Duration::from_secs(10); + let mut last_observation = "gateway unavailable".to_owned(); while Instant::now() < deadline { - if let Ok(response) = client.get(url).send().await - && response.status().is_success() - && let Ok(snapshot) = response.json::().await - && issue_runtime_state_visible(&snapshot, identifier, "running") - { - return Ok(()); + match client.get(url).send().await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(snapshot) => { + if issue_runtime_state_visible(&snapshot, identifier, "running") { + return Ok(()); + } + last_observation = snapshot["snapshot"]["issues"] + .as_array() + .map(|issues| { + issues + .iter() + .map(|issue| { + format!( + "{}:{}", + issue["identifier"].as_str().unwrap_or("?"), + issue["runtime_state"].as_str().unwrap_or("?") + ) + }) + .collect::>() + .join(",") + }) + .unwrap_or_else(|| "snapshot issues unavailable".into()); + } + Err(error) => last_observation = format!("invalid snapshot: {error}"), + } + } + Ok(response) => last_observation = format!("HTTP {}", response.status()), + Err(error) => last_observation = format!("gateway unavailable: {error}"), } sleep(Duration::from_millis(50)).await; } - Err(format!("timed out waiting for running issue at {url}")) + Err(format!( + "timed out waiting for running issue at {url}; last observation: {last_observation}" + )) } async fn wait_for_openhands_interrupt(openhands: &FakeOpenHandsServer) -> Result<(), String> { diff --git a/crates/opensymphony-cli/tests/tui.rs b/crates/opensymphony-cli/tests/tui.rs index d1cf13479..a5c3811d8 100644 --- a/crates/opensymphony-cli/tests/tui.rs +++ b/crates/opensymphony-cli/tests/tui.rs @@ -60,6 +60,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 50_000, }, issues: vec![IssueSnapshot { + harness_capability: None, identifier: "COE-271".to_owned(), title: "FrankenTUI operator client".to_owned(), tracker_state: "In Progress".to_owned(), diff --git a/crates/opensymphony-control/src/lib.rs b/crates/opensymphony-control/src/lib.rs index 860ac456b..4222acb7d 100644 --- a/crates/opensymphony-control/src/lib.rs +++ b/crates/opensymphony-control/src/lib.rs @@ -455,6 +455,7 @@ mod tests { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), tracker_state: "In Progress".to_owned(), diff --git a/crates/opensymphony-control/tests/control_plane.rs b/crates/opensymphony-control/tests/control_plane.rs index 270d919ee..37cef2a93 100644 --- a/crates/opensymphony-control/tests/control_plane.rs +++ b/crates/opensymphony-control/tests/control_plane.rs @@ -45,6 +45,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), tracker_state: "In Progress".to_owned(), diff --git a/crates/opensymphony-domain/src/control_plane.rs b/crates/opensymphony-domain/src/control_plane.rs index 95c9fa760..2b8885886 100644 --- a/crates/opensymphony-domain/src/control_plane.rs +++ b/crates/opensymphony-domain/src/control_plane.rs @@ -87,6 +87,9 @@ pub struct ControlPlaneMetricsSnapshot { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ControlPlaneIssueSnapshot { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_capability: + Option, pub identifier: String, pub title: String, pub tracker_state: String, diff --git a/crates/opensymphony-domain/src/lib.rs b/crates/opensymphony-domain/src/lib.rs index 17a99f576..b43bcc3a4 100644 --- a/crates/opensymphony-domain/src/lib.rs +++ b/crates/opensymphony-domain/src/lib.rs @@ -197,6 +197,7 @@ mod tests { fn sample_conversation(fresh_conversation: bool) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: must(super::ConversationId::new("conv_260")), server_base_url: Some("http://127.0.0.1:3000".to_owned()), transport_target: Some("loopback".to_owned()), diff --git a/crates/opensymphony-domain/src/runtime.rs b/crates/opensymphony-domain/src/runtime.rs index 60942d52d..1f5aafc94 100644 --- a/crates/opensymphony-domain/src/runtime.rs +++ b/crates/opensymphony-domain/src/runtime.rs @@ -519,6 +519,9 @@ pub enum RuntimeStreamState { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConversationMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_capability: + Option>, pub conversation_id: ConversationId, pub server_base_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1162,6 +1165,7 @@ mod tests { fn conversation() -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: must(ConversationId::new("conv_1")), server_base_url: None, transport_target: None, diff --git a/crates/opensymphony-domain/tests/snapshot_serialization.rs b/crates/opensymphony-domain/tests/snapshot_serialization.rs index 7aaad6d87..dfbb85672 100644 --- a/crates/opensymphony-domain/tests/snapshot_serialization.rs +++ b/crates/opensymphony-domain/tests/snapshot_serialization.rs @@ -41,6 +41,7 @@ fn fixture() -> SnapshotEnvelope { total_cost_micros: 250_000, }, issues: vec![IssueSnapshot { + harness_capability: None, identifier: "COE-269".to_owned(), title: "Control-plane API and snapshot store".to_owned(), tracker_state: "In Progress".to_owned(), diff --git a/crates/opensymphony-gateway-schema/src/capability.rs b/crates/opensymphony-gateway-schema/src/capability.rs index b8ae5ee5a..6d192da38 100644 --- a/crates/opensymphony-gateway-schema/src/capability.rs +++ b/crates/opensymphony-gateway-schema/src/capability.rs @@ -11,6 +11,8 @@ pub struct GatewayCapabilities { pub transports: Vec, #[serde(default)] pub harnesses: Vec, + #[serde(default)] + pub harness_profiles: Vec, pub features: Vec, pub auth_modes: Vec, pub max_event_page_size: u32, @@ -128,13 +130,15 @@ pub enum HarnessKind { OpenHandsAgentServer, CodexAppServer, RustNative, + Acp, } impl HarnessKind { - pub const ALL: [Self; 3] = [ + pub const ALL: [Self; 4] = [ Self::OpenHandsAgentServer, Self::CodexAppServer, Self::RustNative, + Self::Acp, ]; pub fn parse(value: &str) -> Option { @@ -142,6 +146,7 @@ impl HarnessKind { "openhands_agent_server" => Some(Self::OpenHandsAgentServer), "codex_app_server" => Some(Self::CodexAppServer), "rust_native" => Some(Self::RustNative), + "acp" => Some(Self::Acp), _ => None, } } @@ -151,6 +156,7 @@ impl HarnessKind { Self::OpenHandsAgentServer => "openhands_agent_server", Self::CodexAppServer => "codex_app_server", Self::RustNative => "rust_native", + Self::Acp => "acp", } } @@ -163,6 +169,7 @@ impl HarnessKind { Self::OpenHandsAgentServer => HarnessCapability::openhands_agent_server(), Self::CodexAppServer => HarnessCapability::codex_app_server_local(), Self::RustNative => HarnessCapability::rust_native_future(), + Self::Acp => HarnessCapability::acp(), } } } @@ -322,6 +329,39 @@ impl HarnessCapability { } } + pub fn acp() -> Self { + Self { + kind: "acp".into(), + display_name: "ACP agent".into(), + available: true, + adapter_contract_version: "harness-adapter-v1".into(), + runtime_contract_version: Some("acp-v1".into()), + actions: HarnessActionCapability { + start_run: true, send_user_message: false, retry: true, cancel: true, + pause: false, resume: false, approve: false, reject: false, comment: false, + }, + event_streams: HarnessEventStreamCapability { + runtime_events: true, terminal_frames: false, replay_from_cursor: false, + raw_payload_refs: true, delivery_modes: vec!["json_rpc_notifications".into()], + }, + approvals: HarnessApprovalCapability { tool_approval: false, human_decision: false, policy_metadata: false }, + model_settings: HarnessModelSettingsCapability { + api_compatible_settings: false, subscription_credentials: false, + per_run_overrides: true, credential_reference_kinds: vec!["env".into()], + }, + transport: HarnessTransportCapability { + protocol: "json_rpc_2_0".into(), modes: vec!["stdio".into()], local: true, remote: false, + }, + cancellation: HarnessCancellationCapability { cancel_run: true, force_stop: true, acknowledges_cancel: true }, + pause_resume: HarnessPauseResumeCapability { pause: false, resume: false }, + history: HarnessHistoryCapability { + fetch_history: false, reconcile_after_ready: false, reconnect_and_replay: false, preserve_unknown_events: true, + }, + notes: vec!["ACP v1 host-owned stdio sessions; profile preflight and negotiated run support are separate from adapter availability.".into()], + feature_gaps: vec!["Operator responses require the operator-request routing implementation.".into(), "Context restoration requires negotiated session/load or session/resume support; uncertain prompt delivery remains fenced.".into()], + } + } + pub fn codex_app_server_future() -> Self { Self { kind: "codex_app_server".into(), @@ -471,3 +511,30 @@ pub enum AuthMode { BearerToken, SubscriptionOAuth, } + +/// A configured profile can pass local preflight without proving agent authentication. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessProfileCapability { + pub harness: String, + pub profile_id: String, + pub preflight_ready: bool, + pub unavailable_reason: Option, +} + +/// Effective features negotiated for one execution; absent usage remains absent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessRunCapability { + pub harness: String, + pub profile_id: String, + pub protocol: String, + pub protocol_version: u16, + pub rpc: String, + pub encoding: String, + pub framing: String, + pub carrier: String, + pub session_restore: bool, + pub history_replay: bool, + pub model_selection: bool, + pub cancellation: bool, + pub operator_responses: bool, +} diff --git a/crates/opensymphony-gateway-schema/src/run.rs b/crates/opensymphony-gateway-schema/src/run.rs index 8be4e88a6..af61e72e1 100644 --- a/crates/opensymphony-gateway-schema/src/run.rs +++ b/crates/opensymphony-gateway-schema/src/run.rs @@ -28,6 +28,9 @@ pub enum RunLifecycleState { /// Run detail exposed by `/api/v1/runs/{run_id}`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunDetail { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_capability: + Option, pub schema_version: SchemaVersion, pub run_id: String, pub issue_id: String, diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index f8e8e5738..42d7b7ab3 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -539,6 +539,7 @@ fn task_graph_node_roundtrips() { #[test] fn run_detail_roundtrips() { let run = RunDetail { + harness_capability: None, schema_version: SchemaVersion::v1(), run_id: "run-1".into(), issue_id: "issue-1".into(), @@ -764,6 +765,7 @@ fn planning_session_summary_roundtrips() { #[test] fn gateway_capabilities_roundtrips() { let caps = GatewayCapabilities { + harness_profiles: Vec::new(), schema_version: SchemaVersion::v1(), gateway_version: "1.6.0".into(), supported_api_versions: vec!["1.0.0".into()], @@ -794,17 +796,21 @@ fn gateway_capabilities_roundtrips() { } #[test] -fn harness_capability_roundtrips_future_adapters() { +fn harness_capability_roundtrips_available_and_future_adapters() { let caps = vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), HarnessCapability::rust_native_future(), + HarnessCapability::acp(), ]; let json = must_serialize(&caps); let back: Vec = must_deserialize(&json); - assert_eq!(back.len(), 3); + assert_eq!(back.len(), 4); + assert_eq!(back[3].kind, "acp"); + assert!(back[3].available && back[3].actions.start_run); + assert!(!back[3].approvals.human_decision); assert!(back[0].available); assert_eq!(back[1].kind, "codex_app_server"); assert_eq!(back[1].transport.protocol, "json_rpc_2_0"); @@ -1682,3 +1688,28 @@ fn harness_scheduler_disagreement_roundtrips() { assert_eq!(diag.scheduler_status, back.scheduler_status); assert_eq!(diag.harness_status, back.harness_status); } + +#[test] +fn acp_profile_and_run_capabilities_share_typescript_fixture() { + use opensymphony::opensymphony_gateway_schema::capability::{ + HarnessProfileCapability, HarnessRunCapability, + }; + let fixture: Value = serde_json::from_str(include_str!( + "../../../packages/gateway-schema/__tests__/fixtures/acp-capability.json" + )) + .expect("fixture"); + let profile: HarnessProfileCapability = + serde_json::from_value(fixture["profile"].clone()).expect("profile DTO"); + let run: HarnessRunCapability = + serde_json::from_value(fixture["run"].clone()).expect("run DTO"); + assert!(!profile.preflight_ready); + assert!(run.session_restore); + assert_eq!( + serde_json::to_value(profile).expect("profile round trip"), + fixture["profile"] + ); + assert_eq!( + serde_json::to_value(run).expect("run round trip"), + fixture["run"] + ); +} diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index bb9985484..3f658b6d5 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -299,6 +299,8 @@ pub struct GatewayState { pub linear_mutations: Option>, pub linear_task_graph: Option>, pub memory_config: Option, + pub harness_profiles: + Arc>, /// Normalized (trimmed, lowercased) workflow active-state names. The task /// graph consults these to decide whether an Idle issue is actually /// dispatchable — the authoritative signal the scheduler itself uses — @@ -328,6 +330,7 @@ impl Clone for GatewayState { linear_mutations: self.linear_mutations.clone(), linear_task_graph: self.linear_task_graph.clone(), memory_config: self.memory_config.clone(), + harness_profiles: self.harness_profiles.clone(), active_states: self.active_states.clone(), terminal_states: self.terminal_states.clone(), codex_readiness_cache: self.codex_readiness_cache.clone(), @@ -597,6 +600,8 @@ pub struct GatewayServer { linear_mutations: Option>, linear_task_graph: Option>, memory_config: Option, + harness_profiles: + Arc>, active_states: Arc>, terminal_states: Arc>, comparison_bases: WorkspaceComparisonBases, @@ -613,6 +618,7 @@ impl Clone for GatewayServer { linear_mutations: self.linear_mutations.clone(), linear_task_graph: self.linear_task_graph.clone(), memory_config: self.memory_config.clone(), + harness_profiles: self.harness_profiles.clone(), active_states: self.active_states.clone(), terminal_states: self.terminal_states.clone(), comparison_bases: self.comparison_bases.clone(), @@ -671,6 +677,7 @@ impl GatewayServer { linear_mutations: None, linear_task_graph: None, memory_config: None, + harness_profiles: Arc::new(Vec::new()), active_states: Arc::new(HashSet::new()), terminal_states: Arc::new(HashSet::new()), comparison_bases: WorkspaceComparisonBases::default(), @@ -692,6 +699,7 @@ impl GatewayServer { linear_mutations: None, linear_task_graph: None, memory_config: None, + harness_profiles: Arc::new(Vec::new()), active_states: Arc::new(HashSet::new()), terminal_states: Arc::new(HashSet::new()), comparison_bases: WorkspaceComparisonBases::default(), @@ -699,6 +707,14 @@ impl GatewayServer { } } + pub fn with_harness_profiles( + mut self, + profiles: Vec, + ) -> Self { + self.harness_profiles = Arc::new(profiles); + self + } + /// Enable serving of the built web client from the given directory. pub fn with_web_assets(mut self, dir: impl Into) -> Self { self.web_assets_dir = Some(dir.into()); @@ -782,6 +798,7 @@ impl GatewayServer { linear_mutations: self.linear_mutations.clone(), linear_task_graph: self.linear_task_graph.clone(), memory_config: self.memory_config.clone(), + harness_profiles: self.harness_profiles.clone(), active_states: self.active_states.clone(), terminal_states: self.terminal_states.clone(), codex_readiness_cache: Arc::new(CodexReadinessCache::default()), @@ -1030,6 +1047,7 @@ fn recent_event_kind_to_snapshot_event_kind( fn build_capabilities() -> GatewayCapabilities { GatewayCapabilities { + harness_profiles: Vec::new(), schema_version: SchemaVersion::v1(), gateway_version: env!("CARGO_PKG_VERSION").into(), supported_api_versions: vec!["1.0.0".into()], @@ -1056,6 +1074,7 @@ fn build_capabilities() -> GatewayCapabilities { harnesses: vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), + HarnessCapability::acp(), HarnessCapability::rust_native_future(), ], features: vec![ @@ -1156,8 +1175,10 @@ fn build_capabilities() -> GatewayCapabilities { } } -async fn capabilities() -> Json { - Json(build_capabilities()) +async fn capabilities(State(state): State) -> Json { + let mut capabilities = build_capabilities(); + capabilities.harness_profiles = state.harness_profiles.as_ref().clone(); + Json(capabilities) } pub fn model_settings_for_llm_api_key(llm_api_key: Option<&str>) -> ModelSettingsResponse { @@ -4615,6 +4636,7 @@ async fn get_run_detail( return ( StatusCode::NOT_FOUND, Json(RunDetail { + harness_capability: None, schema_version: SchemaVersion::v1(), run_id, issue_id: String::new(), @@ -4762,6 +4784,7 @@ async fn get_run_detail( ( StatusCode::OK, Json(RunDetail { + harness_capability: issue.harness_capability.clone(), schema_version: SchemaVersion::v1(), run_id: issue.identifier.clone(), issue_id: issue.identifier.clone(), @@ -4798,7 +4821,9 @@ async fn get_run_detail( // A Codex run carries a Codex thread id; report the harness so the // desktop opens the codex:// deep link instead of the OpenHands // workspace-copy fallback. - harness_type: if issue.codex_thread_id.is_some() { + harness_type: if issue.transport_target.as_deref() == Some("acp") { + Some("acp".into()) + } else if issue.codex_thread_id.is_some() { Some("codex_app_server".into()) } else { issue.server_base_url.as_ref().map(|_| "openhands".into()) @@ -6485,6 +6510,7 @@ exit 2 flags: TestIssueFlags, ) -> ControlPlaneIssueSnapshot { ControlPlaneIssueSnapshot { + harness_capability: None, identifier: "COE-414".into(), title: "Test issue".into(), tracker_state: "in_progress".into(), diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index 870160e9a..cbbe1d914 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -715,6 +715,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), tracker_state: "In Progress".to_owned(), @@ -811,6 +812,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { issues: vec![ // Idle issue (eligible for execution) IssueSnapshot { + harness_capability: None, identifier: "COE-300".to_owned(), title: "Idle task".to_owned(), tracker_state: "Todo".to_owned(), @@ -860,6 +862,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // Completed issue with events and modified files IssueSnapshot { + harness_capability: None, identifier: "COE-301".to_owned(), title: "Completed task".to_owned(), tracker_state: "Done".to_owned(), @@ -960,6 +963,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // Failed issue, first attempt (no retries exhausted) IssueSnapshot { + harness_capability: None, identifier: "COE-302".to_owned(), title: "Failed task".to_owned(), tracker_state: "In Progress".to_owned(), @@ -1009,6 +1013,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // RetryQueued issue: queued but NOT eligible (not idle) IssueSnapshot { + harness_capability: None, identifier: "COE-303".to_owned(), title: "Retry queued task".to_owned(), tracker_state: "In Progress".to_owned(), @@ -1058,6 +1063,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // Blocked Idle issue: NOT eligible AND NOT queued IssueSnapshot { + harness_capability: None, identifier: "COE-304".to_owned(), title: "Blocked idle task".to_owned(), tracker_state: "Todo".to_owned(), @@ -1165,6 +1171,7 @@ fn control_plane_to_dashboard_snapshot_handles_empty_issues() { #[test] fn gateway_capabilities_json_fixture_roundtrips() { let caps = GatewayCapabilities { + harness_profiles: Vec::new(), schema_version: opensymphony::opensymphony_gateway_schema::version::SchemaVersion::v1(), gateway_version: "1.6.0".into(), supported_api_versions: vec!["1.0.0".into()], @@ -1365,6 +1372,15 @@ async fn gateway_serves_capabilities_and_dashboard_snapshot() { == Some("codex-app-server-json-rpc-v2") && harness.transport.modes == vec!["stdio"]) ); + assert!( + caps_response + .harnesses + .iter() + .any(|harness| harness.kind == "acp" + && harness.available + && harness.transport.modes == ["stdio"]) + ); + assert!(caps_response.harness_profiles.is_empty()); assert!( caps_response .features diff --git a/crates/opensymphony-openhands/src/client.rs b/crates/opensymphony-openhands/src/client.rs index 7f19a196f..3403d9e45 100644 --- a/crates/opensymphony-openhands/src/client.rs +++ b/crates/opensymphony-openhands/src/client.rs @@ -578,7 +578,7 @@ impl RuntimeEventStream { pub async fn next_event(&mut self) -> Result, OpenHandsError> { loop { - if let Some(event) = self.poll_next_event_once().await? { + if let Some(event) = Box::pin(self.poll_next_event_once()).await? { return Ok(Some(event)); } @@ -1207,10 +1207,19 @@ impl OpenHandsClient { conversation_id: Uuid, config: RuntimeStreamConfig, ) -> Result { - let conversation = self.get_conversation(conversation_id).await?; - RuntimeEventStream::new(self.clone(), conversation_id, config, conversation) - .attach() + let client = self.clone(); + let mut tasks = tokio::task::JoinSet::new(); + tasks.spawn(async move { + let conversation = client.get_conversation(conversation_id).await?; + RuntimeEventStream::new(client, conversation_id, config, conversation) + .attach() + .await + }); + tasks + .join_next() .await + .expect("attach task exists") + .map_err(|error| OpenHandsError::transport("attach runtime stream", error))? } pub async fn attach_runtime_stream_with_recent_events( diff --git a/crates/opensymphony-openhands/src/session.rs b/crates/opensymphony-openhands/src/session.rs index eccc2eded..cb4e2dd7c 100644 --- a/crates/opensymphony-openhands/src/session.rs +++ b/crates/opensymphony-openhands/src/session.rs @@ -992,6 +992,7 @@ impl IssueConversationManifest { fn to_domain_metadata(&self, stream_state: RuntimeStreamState) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: self.conversation_id.clone(), server_base_url: self.server_base_url.clone(), transport_target: self.transport_target.clone(), @@ -1639,16 +1640,15 @@ impl IssueSessionRunner { const MAX_CONTEXT_OVERFLOW_RETRIES: usize = 1; let observed_run = observed_run_for_turn(run); - let active_session = match self - .initialize_session( - workspace_manager, - workspace, - run_manifest, - &observed_run, - issue, - workflow, - ) - .await? + let active_session = match Box::pin(self.initialize_session( + workspace_manager, + workspace, + run_manifest, + &observed_run, + issue, + workflow, + )) + .await? { Step::Continue(session) => session, Step::EarlyResult(result) => return Ok(*result), @@ -1674,8 +1674,8 @@ impl IssueSessionRunner { let mut retry_count = 0; let mut current_session = active_session; let (final_session, outcome) = loop { - let (mut active_session, outcome, turn_launch_reported) = match self - .execute_turn( + let (mut active_session, outcome, turn_launch_reported) = + match Box::pin(self.execute_turn( workspace_manager, workspace, run_manifest, @@ -1686,12 +1686,12 @@ impl IssueSessionRunner { current_session, launch_reported, observer, - ) + )) .await? - { - Step::Continue(result) => result, - Step::EarlyResult(result) => return Ok(*result), - }; + { + Step::Continue(result) => result, + Step::EarlyResult(result) => return Ok(*result), + }; launch_reported = turn_launch_reported; if is_context_overflow_outcome(&outcome) && retry_count < MAX_CONTEXT_OVERFLOW_RETRIES { @@ -1866,48 +1866,45 @@ impl IssueSessionRunner { where O: IssueSessionObserver, { - let (mut active_session, mut prepared_turn) = match self - .prepare_turn( - workspace_manager, - workspace, - run_manifest, - observed_run, - workflow, - issue, - run, - active_session, - launch_reported, - observer, - ) - .await? + let (mut active_session, mut prepared_turn) = match Box::pin(self.prepare_turn( + workspace_manager, + workspace, + run_manifest, + observed_run, + workflow, + issue, + run, + active_session, + launch_reported, + observer, + )) + .await? { Step::Continue(state) => state, Step::EarlyResult(result) => return Ok(Step::EarlyResult(result)), }; - active_session = match self - .start_turn( - workspace_manager, - workspace, - run_manifest, - observed_run, - active_session, - &mut prepared_turn, - observer, - ) - .await? + active_session = match Box::pin(self.start_turn( + workspace_manager, + workspace, + run_manifest, + observed_run, + active_session, + &mut prepared_turn, + observer, + )) + .await? { Step::Continue(session) => session, Step::EarlyResult(result) => return Ok(Step::EarlyResult(result)), }; - let outcome = self - .await_terminal_outcome( - &mut active_session, - &prepared_turn.baseline_event_ids, - observer, - ) - .await; + let outcome = Box::pin(self.await_terminal_outcome( + &mut active_session, + &prepared_turn.baseline_event_ids, + observer, + )) + .await; Ok(Step::Continue(( active_session, @@ -2997,13 +2994,11 @@ impl IssueSessionRunner { .write_run_manifest(workspace, run_manifest) .await?; - let stream = match self - .client - .attach_runtime_stream( - conversation.conversation_id, - self.config.runtime_stream.clone(), - ) - .await + let stream = match Box::pin(self.client.attach_runtime_stream( + conversation.conversation_id, + self.config.runtime_stream.clone(), + )) + .await { Ok(stream) => stream, Err(error) => { @@ -3570,9 +3565,12 @@ impl IssueSessionRunner { next_token_accumulation = Instant::now() + Duration::from_secs(15); } - match self - .terminal_outcome_from_state(&mut session.stream, baseline_event_ids, observer) - .await + match Box::pin(self.terminal_outcome_from_state( + &mut session.stream, + baseline_event_ids, + observer, + )) + .await { StateCheckResult::Terminal(outcome) => { session.accumulate_tokens(); @@ -3597,7 +3595,8 @@ impl IssueSessionRunner { let event_timeout = compute_timeout_duration(&tracker, next_token_accumulation, now, now_ts); - let next_event = timeout_at(now + event_timeout, session.stream.next_event()).await; + let next_event = + timeout_at(now + event_timeout, Box::pin(session.stream.next_event())).await; match next_event { Err(_) => { @@ -4339,6 +4338,7 @@ fn build_summary_metadata( server_base_url: &str, ) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: ConversationId::new(conversation.conversation_id.to_string()) .expect("UUID-backed conversation ID should not be empty"), server_base_url: Some(server_base_url.to_string()), diff --git a/crates/opensymphony-orchestrator/src/lib.rs b/crates/opensymphony-orchestrator/src/lib.rs index 1ebddf241..fc98647d2 100644 --- a/crates/opensymphony-orchestrator/src/lib.rs +++ b/crates/opensymphony-orchestrator/src/lib.rs @@ -123,6 +123,7 @@ mod tests { TimestampMs::new(11), DurationMs::new(300_000), Some(ConversationMetadata { + harness_capability: None, conversation_id: must(ConversationId::new("conv_260")), server_base_url: Some("http://127.0.0.1:3000".to_owned()), transport_target: Some("loopback".to_owned()), diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 7de329f39..14cf5be63 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -245,10 +245,12 @@ pub struct WorkerStartRequest { pub parent_repair: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HarnessRouteDecision { pub task_type: String, pub harness_kind: String, + #[serde(default)] + pub harness_profile: Option, pub model: Option, pub model_profile: Option, pub reason: String, @@ -264,8 +266,13 @@ impl HarnessRouteDecision { .unwrap_or(""); let model = self.model.as_deref().unwrap_or(""); let mode = if self.dry_run { "dry-run " } else { "" }; + let harness_profile = self + .harness_profile + .as_deref() + .map(|profile| format!(" profile `{profile}`")) + .unwrap_or_default(); format!( - "{mode}selected harness `{}` with model `{model}` and profile `{profile}`: {}", + "{mode}selected harness `{}`{harness_profile} with model `{model}` and profile `{profile}`: {}", self.harness_kind, self.reason ) } @@ -294,6 +301,10 @@ pub enum WorkerUpdate { worker_id: WorkerId, conversation: ConversationMetadata, }, + HarnessCapabilityUpdate { + worker_id: WorkerId, + capability: crate::opensymphony_gateway_schema::capability::HarnessRunCapability, + }, TokenUsageUpdate { worker_id: WorkerId, input_tokens: u64, @@ -6838,6 +6849,24 @@ where execution.update_conversation(conversation); } } + WorkerUpdate::HarnessCapabilityUpdate { + worker_id, + capability, + } => { + let Some(issue_id) = self + .worker_metadata + .get(&worker_id) + .map(|metadata| metadata.issue_id.clone()) + else { + continue; + }; + if let Some(execution) = self.executions.get_mut(&issue_id) + && let Some(mut conversation) = execution.conversation().cloned() + { + conversation.harness_capability = Some(Box::new(capability)); + execution.update_conversation(conversation); + } + } WorkerUpdate::TokenUsageUpdate { worker_id, input_tokens, @@ -8902,6 +8931,7 @@ pub fn decide_issue_route( Ok(HarnessRouteDecision { task_type: ROUTING_TASK_ISSUE_EXECUTION.into(), harness_kind: config.routing.harness.clone(), + harness_profile: config.routing.harness_profile.clone(), model: config.routing.model.clone(), model_profile: config.routing.model_profile.clone(), reason: routing_reason(&config.routing), @@ -9629,6 +9659,45 @@ fn observe_parent_command_event( let Some(payload) = payload else { return Ok(()); }; + if kind == "acp.command_started" { + if let (Some(command_id), Some(command)) = ( + payload + .get("command_id") + .and_then(serde_json::Value::as_str), + payload.get("command").and_then(serde_json::Value::as_str), + ) { + let cwd = payload.get("cwd").and_then(serde_json::Value::as_str); + let root = observed_parent_command_root(controller, parent_workspace_path, cwd)?; + controller.observe_command_started( + attempt_id, + command_id, + command, + root, + observed_at, + )?; + } + return Ok(()); + } + if kind == "acp.command_finished" { + if let (Some(command_id), Some(exit_code)) = ( + payload + .get("command_id") + .and_then(serde_json::Value::as_str), + payload + .get("exit_code") + .and_then(serde_json::Value::as_i64) + .and_then(|value| i32::try_from(value).ok()), + ) { + controller.observe_command_finished( + attempt_id, + command_id, + exit_code, + None, + observed_at, + )?; + } + return Ok(()); + } if kind == "codex.item/started" || kind == "codex.item/completed" { let params = payload.get("params").unwrap_or(payload); let item = params.get("item").unwrap_or(params); @@ -9923,7 +9992,7 @@ mod tests { } #[test] - fn openhands_command_events_supply_exit_and_teardown_receipts() { + fn runtime_command_events_supply_parent_verification_receipts() { let mut controller = ParentIntegrationController::new( IssueId::new("parent-command-events").expect("parent id"), 1, @@ -10033,6 +10102,49 @@ mod tests { && resource.status == crate::opensymphony_orchestrator::ParentResourceStatus::Released })); + + observe_parent_command_event( + &mut controller, + &attempt_id, + Some(Path::new("/parent")), + TimestampMs::new(30), + Some("acp-1-2"), + Some("acp.command_started"), + Some(&serde_json::json!({"command_id":"terminal-1","command":"cargo test","cwd":"/parent/repositories/one"})), + ) + .expect("ACP command start"); + observe_parent_command_event( + &mut controller, + &attempt_id, + Some(Path::new("/parent")), + TimestampMs::new(40), + Some("acp-1-4"), + Some("acp.command_finished"), + Some(&serde_json::json!({"command_id":"terminal-1","exit_code":0})), + ) + .expect("ACP command finish"); + let evidence = crate::opensymphony_domain::ParentVerificationEvidence { + schema_version: 1, + run_id: "run-parent".into(), + attempt: 1, + hierarchy_generation: controller.hierarchy_generation, + repository_commits: controller + .targets + .iter() + .map(|(repository_id, target)| { + (repository_id.clone(), target.target_commit.clone()) + }) + .collect(), + command: "cargo test".into(), + command_hash: crate::opensymphony_orchestrator::parent_command_identity("cargo test"), + root: "checkout-one".into(), + repair_repository_id: None, + }; + assert!( + controller + .record_verification_evidence(&attempt_id, &evidence) + .expect("ACP verification evidence") + ); } #[test] diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 170223707..fa0a38327 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -240,6 +240,25 @@ fn selected_route_uses_configured_codex_harness_and_model() { assert!(route.reason.contains("workflow routing.harness")); } +#[test] +fn selected_acp_route_persists_profile_and_model_identity() { + let issue = normalized_issue("lin-acp", "COE-611", "In Progress"); + let mut config = scheduler_config(); + config.routing.harness = "acp".into(); + config.routing.harness_profile = Some("second".into()); + config.routing.model = Some("agent-model".into()); + let route = decide_issue_route(&issue, &config).expect("ACP route"); + assert_eq!(route.harness_kind, "acp"); + assert_eq!(route.harness_profile.as_deref(), Some("second")); + assert_eq!(route.model.as_deref(), Some("agent-model")); + let persisted = serde_json::to_value(&route).expect("route JSON"); + assert_eq!( + serde_json::from_value::(persisted) + .expect("persisted route"), + route + ); +} + #[test] fn selected_route_rejects_unavailable_harness() { let issue = normalized_issue("lin-430", "COE-430", "In Progress"); @@ -306,6 +325,7 @@ fn workspace_record(identifier: &str, path: &str) -> WorkspaceRecord { fn conversation(worker_id: &WorkerId) -> ConversationMetadata { ConversationMetadata { + harness_capability: None, conversation_id: ConversationId::new(format!("conv-{}", worker_id.as_str())) .expect("conversation id should be valid"), server_base_url: Some("http://127.0.0.1:8000".to_string()), diff --git a/crates/opensymphony-tui/src/lib.rs b/crates/opensymphony-tui/src/lib.rs index ebde8191d..b687f504c 100644 --- a/crates/opensymphony-tui/src/lib.rs +++ b/crates/opensymphony-tui/src/lib.rs @@ -4395,6 +4395,7 @@ mod tests { }, issues: (0..issue_count) .map(|index| IssueSnapshot { + harness_capability: None, identifier: format!("COE-{}", 255 + index), title: format!("Issue {index}"), tracker_state: "In Progress".to_owned(), diff --git a/crates/opensymphony-tui/tests/reducer.rs b/crates/opensymphony-tui/tests/reducer.rs index 250209748..fd0585972 100644 --- a/crates/opensymphony-tui/tests/reducer.rs +++ b/crates/opensymphony-tui/tests/reducer.rs @@ -57,6 +57,7 @@ fn fixture_with_identifiers(sequence: u64, identifiers: &[String]) -> SnapshotEn .iter() .enumerate() .map(|(index, identifier)| IssueSnapshot { + harness_capability: None, identifier: identifier.clone(), title: format!("Issue {identifier}"), tracker_state: "In Progress".to_owned(), @@ -121,6 +122,7 @@ fn reordered_fixture(sequence: u64, identifiers: &[&str]) -> SnapshotEnvelope { .iter() .enumerate() .map(|(index, identifier)| IssueSnapshot { + harness_capability: None, identifier: (*identifier).to_owned(), title: format!("Issue {index}"), tracker_state: "In Progress".to_owned(), diff --git a/crates/opensymphony-workflow/src/acp.rs b/crates/opensymphony-workflow/src/acp.rs index b3a495638..d00f8b529 100644 --- a/crates/opensymphony-workflow/src/acp.rs +++ b/crates/opensymphony-workflow/src/acp.rs @@ -185,7 +185,7 @@ impl AcpConfig { &self, harness: &str, profile: Option<&str>, - model_override: bool, + openhands_model_profile: bool, ) -> Result<(), WorkflowConfigError> { self.validate_profiles()?; match (harness, profile) { @@ -202,9 +202,9 @@ impl AcpConfig { } _ => {} } - if harness == "acp" && model_override { + if harness == "acp" && openhands_model_profile { return Err(invalid( - "ACP model overrides require session configuration support, which is not implemented", + "routing.model_profile is not supported for ACP; select an ACP session model with routing.model", )); } Ok(()) @@ -216,7 +216,7 @@ impl AcpConfig { self.validate_selection( &routing.harness, routing.harness_profile.as_deref(), - routing.model.is_some() || routing.model_profile.is_some(), + routing.model_profile.is_some(), ) } } diff --git a/crates/opensymphony-workflow/src/resolve.rs b/crates/opensymphony-workflow/src/resolve.rs index 966438cdf..346abc0eb 100644 --- a/crates/opensymphony-workflow/src/resolve.rs +++ b/crates/opensymphony-workflow/src/resolve.rs @@ -366,13 +366,13 @@ fn resolve_routing( } fn validate_harness_kind(value: &str, field: &'static str) -> Result<(), WorkflowConfigError> { - if value == "acp" || HarnessKind::parse(value).is_some() { + if HarnessKind::parse(value).is_some() { Ok(()) } else { Err(WorkflowConfigError::InvalidField { field, message: format!( - "must be one of `{}`, `acp`", + "must be one of `{}`", HarnessKind::supported_names().join("`, `") ), }) diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index 7295be0b7..2cb3aed62 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -15,17 +15,18 @@ pub use manager::{ compose_terminal_prompt, }; pub use models::{ - AcpProcessState, AcpRecovery, AcpSessionIdentity, AcpSessionState, AcpSessionStatus, - CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, CleanupIntent, - CleanupOutcome, CleanupRequest, CleanupTarget, CleanupTerminalOutcome, CleanupTombstone, - ConversationManifest, EnsureWorkspaceResult, HookConfig, HookDefinition, HookExecutionRecord, - HookExecutionStatus, HookKind, InstructionProvenance, IssueContextArtifact, IssueDescriptor, - IssueLifecycleState, IssueManifest, ParentCheckoutRequest, ParentChildCheckoutMap, - ParentExecutionManifest, ParentExecutionRoot, ParentIntegrationCheckout, - ParentRetainedCheckout, ParentRuntimeCheckout, ParentRuntimeDescriptor, ParentRuntimeEnvelope, - PromptCaptureDescriptor, PromptCaptureManifest, PromptKind, RunDescriptor, RunManifest, - RunStatus, SSH_AUTH_SOCK_ENV, SessionContextArtifact, TerminalRuntimeEnvelope, WorkspaceHandle, - WorkspaceManagerConfig, checkout_credential_environment_variables, redact_runtime_diagnostic, + AcpProcessState, AcpRecovery, AcpRunRoute, AcpSessionIdentity, AcpSessionState, + AcpSessionStatus, CheckoutManifest, CheckoutRepository, CleanupConfig, CleanupDecision, + CleanupIntent, CleanupOutcome, CleanupRequest, CleanupTarget, CleanupTerminalOutcome, + CleanupTombstone, ConversationManifest, EnsureWorkspaceResult, HookConfig, HookDefinition, + HookExecutionRecord, HookExecutionStatus, HookKind, InstructionProvenance, + IssueContextArtifact, IssueDescriptor, IssueLifecycleState, IssueManifest, + ParentCheckoutRequest, ParentChildCheckoutMap, ParentExecutionManifest, ParentExecutionRoot, + ParentIntegrationCheckout, ParentRetainedCheckout, ParentRuntimeCheckout, + ParentRuntimeDescriptor, ParentRuntimeEnvelope, PromptCaptureDescriptor, PromptCaptureManifest, + PromptKind, RunDescriptor, RunManifest, RunStatus, SSH_AUTH_SOCK_ENV, SessionContextArtifact, + TerminalRuntimeEnvelope, WorkspaceHandle, WorkspaceManagerConfig, + checkout_credential_environment_variables, redact_runtime_diagnostic, }; pub use paths::{ checkout_workspace_key, parent_workspace_key, resolve_path_within_root, sanitize_workspace_key, diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index a042ce0b0..d3a83d586 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -52,6 +52,7 @@ use crate::opensymphony_domain::{RepositoryBinding, SafeRemoteFingerprint}; const MAX_INSTRUCTION_FILE_BYTES: u64 = 1024 * 1024; const MAX_TOTAL_INSTRUCTION_BYTES: u64 = 4 * 1024 * 1024; +#[derive(Clone)] pub struct WorkspaceManager { config: WorkspaceManagerConfig, legacy_repository: Option, diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index bede99581..ff8b223be 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -644,6 +644,7 @@ pub struct RunDescriptor { pub repository_binding: Option, pub runtime_envelope: Option, pub parent_runtime_envelope: Option, + pub acp_route: Option, } impl RunDescriptor { @@ -655,6 +656,7 @@ impl RunDescriptor { repository_binding: None, runtime_envelope: None, parent_runtime_envelope: None, + acp_route: None, } } @@ -686,6 +688,25 @@ impl RunDescriptor { self.parent_runtime_envelope = parent_runtime_envelope; self } + + pub fn with_acp_route(mut self, acp_route: Option) -> Self { + self.acp_route = acp_route; + self + } +} + +/// The ACP route selected for this run. Stored with the prepared run so recovery +/// cannot take model or profile choices from a prior turn's workspace artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AcpRunRoute { + pub task_type: String, + pub harness_kind: String, + pub harness_profile: Option, + pub model: Option, + pub model_profile: Option, + pub reason: String, + pub dry_run: bool, + pub user_override: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1125,6 +1146,8 @@ pub struct RunManifest { pub runtime_envelope: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_runtime_envelope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_route: Option, pub attempt: u32, #[serde(default)] pub normal_retry_count: u32, @@ -1171,6 +1194,7 @@ impl RunManifest { repository_binding: run.repository_binding.clone(), runtime_envelope: run.runtime_envelope.clone(), parent_runtime_envelope: run.parent_runtime_envelope.clone(), + acp_route: run.acp_route.clone(), attempt: run.attempt, normal_retry_count: run.normal_retry_count, pending_retry: false, @@ -1243,14 +1267,30 @@ pub struct AcpSessionState { pub session_id: Option, /// Negotiated initialization metadata, redacted by the protocol client. pub initialization: serde_json::Value, + /// Whether this session advertised a selectable model configuration option. + #[serde(default)] + pub model_selection: bool, pub status: AcpSessionStatus, pub stop_reason: Option, + /// Independent of status, which returns to Ready when a new run claims the session. + /// None identifies a pre-migration manifest whose prior terminal state must be inspected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_prompt_seeded: Option, pub recovery: AcpRecovery, pub owner_id: String, #[serde(default)] pub process: AcpProcessState, } +impl AcpSessionState { + pub fn workflow_prompt_seeded(&self) -> bool { + self.workflow_prompt_seeded.unwrap_or( + self.status != AcpSessionStatus::Ready + && self.stop_reason.as_deref() != Some("cancelled_before_prompt"), + ) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConversationManifest { pub issue_id: String, @@ -1268,6 +1308,8 @@ pub struct ConversationManifest { #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_envelope: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_runtime_envelope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub acp: Option, } @@ -1291,6 +1333,7 @@ impl ConversationManifest { reset_reason: None, runtime_contract_version: runtime_contract_version.into(), runtime_envelope: None, + parent_runtime_envelope: None, acp: None, } } diff --git a/docs/architecture.md b/docs/architecture.md index 4b885e0b1..b3a766a4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -487,8 +487,23 @@ session actor accepts generation-fenced commands and allows one outstanding prompt. Worker handles borrow a process across attempts; dropping a handle or subscriber preserves the session. Idle expiry and explicit retirement use the same supervised process teardown. The `run_turn` compatibility API creates one -session for one prompt. Neither API mutates scheduling state. Production worker -routing is the separate OSYM-902 integration slice. +session for one prompt. Neither API mutates scheduling state. The production +`opensymphony run` worker selects ACP explicitly, borrows the retained owner, and +reports normalized updates and outcomes through scheduler-owned worker messages. +The same launch preparation verifies checkout bindings, instruction provenance, +hooks, review context and scoped memory before adapter dispatch. ACP-only startup +requires neither an OpenHands client nor an OpenHands server. + +The persisted route retains the ACP profile and model selection across daemon +recovery. Profile switches retire a quiescent owner before archiving its manifest; +active prompts, observation leases and uncertain submissions fence switching and +cleanup. A submitted prompt is never replayed on restart. Tool patches merge by +call identity with bounded state; replay frames do not contribute usage. Optional +usage remains absent when the peer does not report it. Raw redacted source frames +stay on the owner separately from the normalized scheduler event stream. +For a bound parent continuation, a changed run-scoped grant rotates the process +while retaining the authoritative session ID. The replacement must negotiate +load or resume; it cannot create a new parent session after a failed restore. Durable ACP identity and submission/outcome markers live in the existing conversation manifest, with additive ACP identity in its runtime envelope. diff --git a/docs/configuration.md b/docs/configuration.md index 67138ade9..273302373 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -965,8 +965,28 @@ launch. These host API settings are independent of profile protocol capabilities `SessionLaunch::require_persistence` rejects peers without negotiated load/resume support. The caller supplies a non-secret credential/grant revision and the scheduler's exact workspace identity; changes prevent session reuse. -Production `opensymphony run` routing is a separate implementation slice; an ACP -route currently fails scheduler capability selection before worker dispatch. +Production `opensymphony run` selects this profile through the scheduler and +persists the effective route under the workspace metadata directory. Recovery +uses the route bound to the prepared `run.json` record even when the current +default profile differs. If `routing.model` is unset, preparation resolves the +profile's `session.model` into that route and the runtime envelope. Older run +records may use the workspace route snapshot +only when the durable ACP identity matches their run ID and attempt. A finished ACP +turn reconciles a recovered run only when its durable run ID and attempt match; +a newly prepared attempt submits its own prompt. ACP-only execution +does not start an OpenHands server. Production retains at most 128 ACP sessions; +the scheduler continues to enforce `agent.max_concurrent_agents`. Select a +different profile only after the retained session can retire safely; uncertain +submissions remain fenced. The same profile ID starts a fresh owner when its +effective command, arguments, session options, host services, or resolved +credential scope changes. +Credential source names follow the host platform's environment-name rules; +Windows aliases differing only in ASCII case resolve to the same credential +when checking whether a retained owner must rotate. +Managed memory run ID, attempt and project-set values are part of that scope; +when they change, the retained ACP process is replaced so its environment +matches the active run. ACP memory prompt guidance uses the scoped worker +overlay that supplies the managed memory attachment. Central profiles remain authoritative over repository-local workflow files. Profile shape is validated at central load with the profile ID and specific validation cause. Harness/profile selection and model restrictions are validated @@ -979,6 +999,11 @@ distinct under host name rules; Windows rejects case-equivalent `env_refs` targe before launch, while POSIX preserves case-distinct variables. Profile arguments are literal argv entries, never shell templates. `env_refs` contains variable names; resolved values stay in the host-owned launch context. +Profile preflight evaluates the resolved worker environment, including Linear +client-credentials overrides, with the same precedence used at launch. References +to checkout-only credential variables are unavailable to ACP profiles, including +when an ambient value happens to exist. Unrelated non-UTF-8 ambient variables +are ignored when assembling the launch environment. The selected authentication method must be an advertised agent-handled method; terminal/browser authentication is not advertised. Omit `auth` for agents with existing login state that need no `authenticate` call. @@ -1008,8 +1033,12 @@ model/mode prerequisites apply before dependent options, using the refreshed advertisements after each response. Legacy `session/set_mode` is used when the peer supplies modes without config options. Unsupported explicit choices fail setup. Boolean options and legacy experimental -model RPCs are not advertised. The generic routing model override remains -unavailable for ACP; use the profile's explicit session selection. +model RPCs are not advertised. `routing.model` and its configured environment override select an ACP model ID +and take precedence over `session.model`. OpenHands `routing.model_profile` is +rejected for ACP. +The negotiated run capability reports model selection when the bound ACP +session advertises a selectable model option; it updates if that capability +changes before a retained prompt. The host supplies `LaunchContext.services: HostServices`. Its `read_files`, `write_files`, and `terminals` flags default to false and enable only the matching diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 585a77b27..00903697e 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -114,15 +114,48 @@ Known gaps: - Hosted execution would need an isolation model before remote support is advertised. -## ACP stdio client foundation - -`opensymphony_acp::run_turn` implements an SDK-backed ACP v1 stdio session through -setup, one text prompt, ordered updates, cancellation, and supervised teardown. -The executable client includes typed launch profiles and host-owned callbacks. -ACP is not yet advertised as a runnable scheduler harness; production routing, -negotiated public capabilities and operator -responses follow the [ACP runtime task package](tasks/acp-runtime-ide-task-package.yaml). - +## ACP stdio runtime + +`acp` is an available local stdio adapter. `opensymphony run` selects a configured +`routing.harness_profile` and executes through the retained `SessionHost` with +ACP v1 JSON-RPC 2.0, UTF-8 and LF framing. Start, continuation, cancellation, +recovery and terminal cleanup use the shared worker and scheduler boundaries. + +`/api/v1/capabilities` separates generic adapter support (`harnesses`) from +configured profile preflight readiness (`harness_profiles`). Preflight checks +profile shape, executable availability and environment references, including +checkout-only credential exclusions. Executable lookup honors profile-mapped +`PATH` values and requires absolute search directories +because no issue cwd is available during preflight. It does not +claim agent authentication or session negotiation succeeded. Run details expose +`harness_capability` only after negotiation, including the selected profile, +load/resume and history replay support. These DTOs are shared by Rust and +TypeScript. Recovery projects the persisted negotiation without launching a peer +or replaying a completed prompt. They contain no commands, arguments, resolved +credentials or source +protocol payloads. Runtime activity carries context occupancy and optional usage +reported with prompt responses separately, with summaries for structural updates +such as plans. Response observations retain reported counters without inferring +delta accumulation into native aggregate totals. Missing counters stay absent +and duplicate or load-replayed observations are suppressed. Numeric usage counters retain their meaning through source redaction; +credential fields remain redacted. +Unrecognized `session/update` variants still emit a bounded, redacted generic +activity record so an active peer remains visible to the scheduler. Subscriber +lag replays the retained source tail when it covers the current run's processed +cursor; only a current gap fences automatic completion. +Filesystem callback requests and responses emit constant-summary, payload-free +activity records, keeping stall detection current without exposing file data. +An unfamiliar bounded prompt stop reason remains terminal peer evidence while +only recognized successful reasons complete the worker successfully. The +known-secret matcher redacts that reason before it reaches durable state or a +worker summary, independently of source-frame redaction. + +Operator responses, writer transfer and IDE presentation follow the +[ACP runtime task package](tasks/acp-runtime-ide-task-package.yaml). Permission +requests produce a scheduler-visible waiting event and receive the protocol +cancellation response until operator handling is available. Unknown stop reasons +and refusals stop automatic retry; uncertain submission or cancellation fences +cleanup until execution risk is reconciled. | ACP client surface | Implemented contract | | --- | --- | @@ -134,6 +167,19 @@ responses follow the [ACP runtime task package](tasks/acp-runtime-ide-task-packa | Permission | Cancelled response until operator routing is implemented. | | Interactive auth and elicitation | Not advertised. | +The production worker enables file and terminal callbacks for its verified +issue workspace and supplies the active scoped memory grant as a host-owned MCP +attachment. An explicit `routing.model` selection takes precedence over the +profile's `session.model` and is validated against negotiated options before +prompt submission. Run capability projection reports the negotiated selectable +model option, while profile preflight uses the same resolved worker environment +as launch. ACP terminal callback create and completion responses project bounded +command receipts for parent final verification; only successful create responses +and observed exit codes count. Running `terminal/output` polls and successful +responses with a null exit status emit constant-summary, payload-free activity +so long commands keep the scheduler idle deadline current without exposing +terminal output or recording a completion receipt. + Windows callback operations pin path ancestors through use and reject reparse points, including junctions, through no-follow handles. Callback epoch handoff shares the setup deadline and observes caller cancellation before prompt submission. diff --git a/docs/openhands-agent-server.md b/docs/openhands-agent-server.md index 1f436eb32..55973a379 100644 --- a/docs/openhands-agent-server.md +++ b/docs/openhands-agent-server.md @@ -138,6 +138,10 @@ barrier before its primary manifest is written, its pending ownership record is cleared only after remote retirement succeeds or equivalent superseded evidence is durable; if both operations fail, the pending record remains for restart recovery and later cleanup. +When the configured harness changes, daemon startup applies the same +run/envelope binding checks to pending OpenHands ownership before deciding +whether to initialize the OpenHands client and managed server. A valid pending +owner is promoted for recovery; stale or incompatible ownership is ignored. ## 6. Conversation model @@ -189,6 +193,10 @@ The internal `opensymphony_openhands` module owns: - ready-state detection - issue session launch and reuse +The local `opensymphony run` worker keeps OpenHands attach and event-poll futures +off its shared routing stack. Attach runs in a cancellation-owned task, so an +aborted issue run also aborts its in-flight attach before it can report launch. + Harness interrupt uses `POST /api/conversations/{id}/interrupt` as the primary mid-turn stop request. If an older agent-server returns a missing-route status for that endpoint, the adapter falls back to `POST /api/conversations/{id}/pause` diff --git a/docs/operations.md b/docs/operations.md index 26f57162f..602969115 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -896,12 +896,14 @@ the client contract; real vendor qualification remains OSYM-906. The default limits are 1 MiB per frame, 128 queued incoming frames with a cumulative 4 MiB wire-byte budget, 128 outstanding callback responses with an independent 4 MiB encoded-byte budget, 256 retained source frames with a cumulative -1 MiB serialized evidence budget, 16 KiB stderr, 30 seconds for setup, 300 seconds -for a prompt, 10 seconds for cancellation acknowledgement, and one 5-second +1 MiB serialized evidence budget, 16 KiB stderr, 30 seconds for setup, an +300-second prompt deadline by default for direct client callers, 10 seconds +for cancellation acknowledgement, and one 5-second deadline for process termination and reaping. Windows launches enter a kill-on-close Job Object before the child resumes, so dropping the turn future also terminates descendants. Unix launches retain process-group ownership for the same drop path. Callers -may pass validated `ClientLimits`. A supplied update channel must be drained +may pass validated `ClientLimits`; a zero `prompt_timeout` disables only the +client's wall-clock prompt deadline. A supplied update channel must be drained concurrently; saturation or receiver loss fails the run visibly. Incoming queue charges are released when each frame reaches SDK dispatch. Its byte budget is independent of frame count, ranges from 256 bytes to 64 MiB, and rejects a single @@ -969,8 +971,71 @@ SSE state/source events and explicit history-gap events when a bounded buffer or subscriber loses data. Tokens belong in the Authorization header. Source frames preserve redacted content and unknown payloads, with connection generation, arrival sequence, run binding and replay origin. Recorded history is bounded by -the client's queue count and byte budgets. Production CLI routing and IDE writer -handoff are separate integration slices. +the client's queue count and byte budgets. IDE writer handoff is a separate +integration slice. + +### Production ACP routing + +Configure `routing.harness: acp`, a named `routing.harness_profile`, and its +`acp.profiles` entry, then use `opensymphony run`. The gateway publishes profile +preflight readiness separately from negotiated run support. An unavailable +executable or missing credential reference is reported without exposing its value. +Retained profile identity survives a default-profile change on restart. A known +ACP profile and model are bound to the prepared run manifest. Recovery rejects +an older unbound prepared run when its workspace route snapshot belongs to a +different ACP run. Claims for a new run clear the old terminal result before launch, +so a crash at that checkpoint cannot complete the new run from the prior turn. +A known terminal prompt is reconciled without sending it again; a possibly submitted +prompt remains uncertain and blocks automatic retry and workspace removal. +Recovered finished turns reconcile only the matching run ID and attempt; a +prepared later run receives its own scoped memory environment and prompt. +An unfamiliar but nonempty bounded ACP `stopReason` is preserved as a finished +peer response and reported as an unsuccessful, non-retryable outcome. Known +credential values in the reason are redacted before durable storage and worker +status projection. The reason does not become an uncertain submission merely +because it is new. +The bound model includes a profile's `session.model` when no routing override is +set. Changes to managed memory run ID, attempt or project set rotate the retained +ACP process so child environment and memory evidence remain scoped to the run. +For an authoritative parent continuation, that rotation archives the old owner +but keeps the conversation manifest and session ID. The new process must +negotiate load or resume; an unavailable restore fails before another prompt +instead of creating a different parent session. A grant change that requires a +fresh conversation is rejected before parent retirement. +After a revoked memory grant requires a fresh owner, the revocation marker clears +when that owner reports a successful launch; failed setup leaves it in place. +ACP prompt guidance reads the managed worker overlay, not inherited shell scope. +The ACP child and its terminal callbacks receive memory variables only from the +run-scoped managed grant; ambient and workflow `OPENSYMPHONY_MEMORY_*` values are +discarded. Profiles cannot remap credentials into or out of that reserved namespace. +Repository-neutral ACP parents receive project, authorized-repository, run, and +attempt guidance from that overlay without an execution-repository default. +Production ACP turns have no fixed client prompt deadline. When configured, +`agent.stall_timeout_ms` applies the scheduler's activity-based stall policy; +the interrupt path handles an operator or scheduler stop request. +Unknown, redacted `session/update` variants produce bounded generic scheduler +activity. After a subscriber lag, the worker replays retained source frames when +they cover every frame after its processed cursor; an actual gap fences the run. +Supported filesystem callback requests and their responses produce payload-free +scheduler activity without recording file paths or contents. +Running terminal output polls and successful responses with a null exit status +likewise advance the idle deadline through payload-free activity; only an +observed exit code records command completion. +An ACP session restored through `session/load` receives the full workflow prompt +when its durable state has never seeded that prompt. A seeded session receives +continuation guidance even when the new run's claim has reset its status to +`ready`. +An exact-run `cancelled_before_prompt` checkpoint reports a cancelled worker +outcome on recovery. +Cleanup after a known-finished owner loss acquires the durable owner lock and +verifies the prior process is absent before recording its stop. Setup failures +before submission permit scheduler retry. Cancellation is accepted after +matching live or durable stopped-state observation. If the owner closes during +pre-submission cancellation, the scheduler verifies matching durable identity, +stopped process state and a `ready` or `finished` status before acknowledging. +Scheduled ACP interrupts identify the active worker by issue ID and issue +identifier, then verify its current owner, generation, run and conversation +before cancellation. diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 786ba47d4..4ac6fa68c 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -717,6 +717,19 @@ owner locks, process-group loss, the launch checkpoint gap, compatibility bounds identity mismatches and legacy manifests. Workspace and control-plane regression targets remain required for changes to persistence or the host observation seam. +`cargo test-system-duckdb --test run run_dispatches_acp` exercises the actual CLI +against a fake tracker and ACP executable with an unreachable OpenHands endpoint. +It verifies exact cwd, workspace hooks, scoped memory, persisted profile routing, +public capabilities and distinct plan/context/turn usage activity with absent +counters preserved. `cargo test-system-duckdb --lib acp` covers continuation, +profile switching and dry runs, persisted recovery, permission waiting, safe +setup retry, ten concurrent workers, interrupt/abort, unreadable submission +evidence and known-finished versus uncertain owner-loss cleanup. Use `tests/fixtures/acp_worker_peer.py` +for production worker fixtures. Gateway/schema tests include a shared Rust and +TypeScript capability fixture; native OpenHands and Codex regression suites remain +required. Unset ambient `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET` for fake +tracker suites so local OAuth configuration does not override fixture credentials. + ## Current model diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 6a538b1b2..065b67581 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -317,12 +317,14 @@ the command whose result should count as final verification. At completion the runtime requires a regular file no larger than 64 KiB, checks its run, attempt, hierarchy generation, command root, and exact repository commit map, and reopens the checkouts at those commits. The selected command must match start -and completion events observed from the Codex or OpenHands runtime. Those +and completion events observed from the Codex, OpenHands, or ACP runtime. Those events, rather than fields supplied by the parent, provide the orchestrator's -deadline, working directory, exit result, bounded output, foreground-process -ownership, and teardown receipt. A reported working directory maps only to the -parent root or an exact verified checkout path, and the selector must name that -observed root. Missing, stale, unobserved, late, or still-active evidence +deadline, working directory, exit result, foreground-process ownership, and +teardown receipt. Codex and OpenHands command events also supply bounded output; +ACP terminal callbacks supply the observed command and exit code. A reported +working directory maps only to the parent root or an exact verified checkout path, +and the selector must name that observed root. Missing, stale, unobserved, late, +or still-active evidence converts a generic successful harness turn into a failed parent attempt. An acknowledged interrupt counts as foreground-process teardown only after the harness has reconciled a stopped state. It does not release separately named @@ -925,6 +927,10 @@ rejected before spawn. Profiles cannot supply a cwd. The host supplies an explic environment with scoped memory grants and checkout credential exclusions. The client clears ambient inheritance and rejects profile references that would reintroduce excluded credentials. +Production routing removes ambient and workflow `OPENSYMPHONY_MEMORY_*` values +before adding the current run's managed grant. Profile environment mappings +cannot read from or write into this reserved namespace; terminal callbacks inherit the same +scoped environment as the ACP child. `SessionHost` retains one supervised process/connection per issue. A stable `.opensymphony/acp-owner.lock` prevents competing owners without creating another @@ -951,6 +957,10 @@ tagged as replay through the load response; subsequent live frames keep their own sequence and run binding. A finished nonpersistent session resets to a fresh agent with an explicit full-context requirement. Its old transcript remains inspection evidence. Unsupported required persistence rejects setup. +When a configured harness changes after a crash, startup checks the same +run/envelope-bound pending OpenHands ownership artifact used by workspace +recovery before deciding whether the OpenHands client and local server are +needed. Incompatible pending ownership is not promoted. On Unix, owner takeover also requires the prior process group to be absent. A crash between the durable launch reservation and process-ID checkpoint leaves @@ -959,6 +969,42 @@ Job Object. Retirement waits for active work and leases to clear, then terminate and reaps owned process resources before acknowledging cleanup. Trusted local host execution provides filesystem/process access; it is not a sandbox. +The production worker binds the selected ACP profile and effective model, +including a profile default when there is no routing override, to `run.json` +when preparing the run. It also persists `harness-route.json` before ACP owner +reservation as the latest workspace route snapshot. Recovery takes its route +from the matching run record; an older run record can use the workspace snapshot +only when the durable ACP identity names that same run and attempt. A claim +for a different run clears the prior turn's terminal status and stop reason +in the synced claim checkpoint. Durable workflow-prompt seeding is tracked +separately from that transient status, including for legacy manifests migrated +before the claim clears their terminal state. The worker keeps the current run's +verified terminal or parent runtime envelope and binds its ACP session without +copying an older retained owner's hierarchy or checkout snapshot. A profile +switch, a change to effective profile configuration or credential scope, or a +harness switch retires the old owner +before archiving its manifest. Interrupted or failed launches execute +`after_run` only when the harness is known stopped. Uncertain work retains its +workspace, memory grant and cleanup fence. A known-finished session whose host +is gone can retire through the same exclusive lock and process-absence check +without launching another peer. +For an authoritative parent continuation, a process or grant revision archives +the retired owner while preserving the bound manifest and session ID. The +replacement claims the new fingerprint under the exclusive owner lock and +restores that ID through negotiated load or resume. Unsupported or missing +restoration fails before prompt submission; a scope change requiring a fresh +conversation is rejected before retirement. +An ACP session that has not seeded its workflow prompt receives the full +workflow prompt on the next attempt, even when `session/load` restores the peer +session ID. A seeded session receives continuation guidance after a new run +resets its transient status to `ready`. Production ACP turns use configured +scheduler stall detection and cancellation rather than a fixed client prompt +deadline; direct client callers can set a bound or use zero to disable it. +When cancellation closes the owner before prompt submission, scheduler stop +observation checks the durable record for the exact owner, generation, run and +workspace. Only a stopped process with `ready` or `finished` state counts as +stopped; submitted or uncertain state keeps cleanup fenced. + Filesystem callbacks require absolute paths in the bound workspace and reject parent traversal, escaping or dangling symlinks and non-regular file targets. diff --git a/packages/gateway-schema/__tests__/capability.test.ts b/packages/gateway-schema/__tests__/capability.test.ts new file mode 100644 index 000000000..15479af45 --- /dev/null +++ b/packages/gateway-schema/__tests__/capability.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { HarnessProfileCapability, HarnessRunCapability } from "@opensymphony/gateway-schema"; + +test("Rust and TypeScript share ACP profile and negotiated run projections", () => { + const fixture = JSON.parse(readFileSync(resolve(__dirname, "fixtures/acp-capability.json"), "utf8")) as { + profile: HarnessProfileCapability; run: HarnessRunCapability; + }; + const ready: boolean = fixture.profile.preflight_ready; + const negotiated: boolean = fixture.run.session_restore; + expect(ready).toBe(false); + expect(negotiated).toBe(true); + expect(fixture.run).not.toHaveProperty("input_tokens"); + expect(fixture.profile).not.toHaveProperty("command"); + expect(JSON.parse(JSON.stringify(fixture))).toEqual(fixture); +}); diff --git a/packages/gateway-schema/__tests__/fixtures/acp-capability.json b/packages/gateway-schema/__tests__/fixtures/acp-capability.json new file mode 100644 index 000000000..a8a2dc7f2 --- /dev/null +++ b/packages/gateway-schema/__tests__/fixtures/acp-capability.json @@ -0,0 +1,23 @@ +{ + "profile": { + "harness": "acp", + "profile_id": "local", + "preflight_ready": false, + "unavailable_reason": "credential_reference_unavailable" + }, + "run": { + "harness": "acp", + "profile_id": "local", + "protocol": "acp", + "protocol_version": 1, + "rpc": "json_rpc_2_0", + "encoding": "utf-8", + "framing": "lf", + "carrier": "stdio", + "session_restore": true, + "history_replay": false, + "model_selection": false, + "cancellation": true, + "operator_responses": false + } +} diff --git a/packages/gateway-schema/src/capability.ts b/packages/gateway-schema/src/capability.ts index fa576fcea..75f095525 100644 --- a/packages/gateway-schema/src/capability.ts +++ b/packages/gateway-schema/src/capability.ts @@ -27,8 +27,78 @@ export interface GatewayCapabilities { gateway_version: string; supported_api_versions: string[]; transports: TransportCapability[]; + harnesses?: HarnessCapability[]; + harness_profiles?: HarnessProfileCapability[]; features: FeatureCapability[]; auth_modes: AuthMode[]; max_event_page_size: number; max_terminal_frame_batch: number; } + +export interface HarnessCapability { + kind: string; + display_name: string; + available: boolean; + adapter_contract_version: string; + runtime_contract_version: string | null; + actions: { + start_run: boolean; + send_user_message: boolean; + retry: boolean; + cancel: boolean; + pause: boolean; + resume: boolean; + approve: boolean; + reject: boolean; + comment: boolean; + }; + event_streams: { + runtime_events: boolean; + terminal_frames: boolean; + replay_from_cursor: boolean; + raw_payload_refs: boolean; + delivery_modes: string[]; + }; + approvals: { tool_approval: boolean; human_decision: boolean; policy_metadata: boolean }; + model_settings: { + api_compatible_settings: boolean; + subscription_credentials: boolean; + per_run_overrides: boolean; + credential_reference_kinds: string[]; + }; + transport: { protocol: string; modes: string[]; local: boolean; remote: boolean }; + cancellation: { cancel_run: boolean; force_stop: boolean; acknowledges_cancel: boolean }; + pause_resume: { pause: boolean; resume: boolean }; + history: { + fetch_history: boolean; + reconcile_after_ready: boolean; + reconnect_and_replay: boolean; + preserve_unknown_events: boolean; + }; + notes: string[]; + feature_gaps: string[]; +} + +/** Local preflight does not prove successful agent authentication. */ +export interface HarnessProfileCapability { + harness: string; + profile_id: string; + preflight_ready: boolean; + unavailable_reason: string | null; +} + +export interface HarnessRunCapability { + harness: string; + profile_id: string; + protocol: string; + protocol_version: number; + rpc: string; + encoding: string; + framing: string; + carrier: string; + session_restore: boolean; + history_replay: boolean; + model_selection: boolean; + cancellation: boolean; + operator_responses: boolean; +} diff --git a/packages/gateway-schema/src/index.ts b/packages/gateway-schema/src/index.ts index 908d5715e..e6d6e2c4a 100644 --- a/packages/gateway-schema/src/index.ts +++ b/packages/gateway-schema/src/index.ts @@ -166,7 +166,7 @@ export type { export type { PlanningArtifactKind, PlanningArtifact, PlanningSessionStatus, PlanningSessionSummary } from "./planning.js"; // capability -export type { AuthMode, TransportCapability, FeatureCapability, GatewayCapabilities } from "./capability.js"; +export type { AuthMode, TransportCapability, FeatureCapability, GatewayCapabilities, HarnessCapability, HarnessProfileCapability, HarnessRunCapability } from "./capability.js"; export { authStateFromError } from "./auth.js"; export type { AuthState, AuthErrorCode } from "./auth.js"; diff --git a/packages/gateway-schema/src/run.ts b/packages/gateway-schema/src/run.ts index f37303ebb..04ee4ca87 100644 --- a/packages/gateway-schema/src/run.ts +++ b/packages/gateway-schema/src/run.ts @@ -1,3 +1,4 @@ +import type { HarnessRunCapability } from "./capability.js"; import type { PageCursor } from "./cursor.js"; import type { SchemaVersion } from "./version.js"; @@ -118,6 +119,7 @@ export type RunAction = /** Run detail exposed by the gateway. */ export interface RunDetail { + harness_capability?: HarnessRunCapability; schema_version: SchemaVersion; run_id: string; issue_id: string; diff --git a/tests/acp.rs b/tests/acp.rs index 20735dc34..3392a77d7 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -53,6 +53,55 @@ fn limits() -> ClientLimits { } } +#[tokio::test(flavor = "current_thread")] +async fn acp_turn_without_client_deadline_survives_five_minutes_of_virtual_time() { + let root = tempfile::tempdir().expect("temp"); + let (tx, mut updates) = tokio::sync::mpsc::channel(4); + let cancellation = CancellationToken::new(); + let turn = tokio::spawn({ + let cancellation = cancellation.clone(); + let profile = profile("cancel"); + let context = context(root.path()); + async move { + run_turn( + &profile, + context, + "long running work".into(), + cancellation, + Some(tx), + ClientLimits { + prompt_timeout: Duration::ZERO, + ..limits() + }, + ) + .await + } + }); + tokio::time::timeout(Duration::from_secs(5), updates.recv()) + .await + .expect("peer update before clock advance") + .expect("peer update"); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(301)).await; + assert!( + !turn.is_finished(), + "active turn must not hit a fixed client deadline" + ); + tokio::time::resume(); + cancellation.cancel(); + let result = tokio::time::timeout(Duration::from_secs(5), turn) + .await + .expect("cancelled turn completes") + .expect("turn task") + .expect("launch"); + assert!( + result + .outcome + .expect("cancel acknowledged") + .cancellation_acknowledged + ); +} + #[tokio::test] async fn acp_process_completes_ordered_callbacks_and_redacts_evidence() { let root = tempfile::tempdir().expect("temp"); @@ -430,6 +479,30 @@ fn acp_workflow_profiles_validate_and_preserve_environment_references() { resolved.config.routing.harness_profile.as_deref(), Some("fake") ); + let selected_model = WorkflowDefinition::parse(&source.replace( + "harness_profile: fake", + "harness_profile: fake\n model: workflow-model", + )) + .expect("model selection"); + assert_eq!( + selected_model + .resolve(Path::new("/repo"), &env) + .expect("workflow model") + .config + .routing + .model + .as_deref(), + Some("workflow-model") + ); + let mut model_environment = env.clone(); + model_environment.insert("OPENSYMPHONY_MODEL".into(), "environment-model".into()); + let model = selected_model + .resolve(Path::new("/repo"), &model_environment) + .expect("environment model") + .config + .routing; + assert_eq!(model.model.as_deref(), Some("environment-model")); + assert!(model.model_from_env); for harness in ["acp", "openhands_agent_server", "codex_app_server"] { let mut overrides = env.clone(); overrides.insert("OPENSYMPHONY_HARNESS".into(), harness.into()); @@ -462,7 +535,7 @@ fn acp_workflow_profiles_validate_and_preserve_environment_references() { ("harness: acp", "harness: openhands_agent_server"), ( "harness_profile: fake", - "harness_profile: fake\n model: some-model", + "harness_profile: fake\n model_profile: openhands-profile", ), ] { let workflow = @@ -788,6 +861,35 @@ async fn acp_configured_environment_targets_follow_platform_name_rules() { } } +#[tokio::test] +async fn acp_profile_cannot_map_unscoped_credential_into_memory_scope() { + let root = tempfile::tempdir().expect("temp"); + for (target, source) in [ + ("OPENSYMPHONY_MEMORY_ADMIN_TOKEN", "AGENT_TOKEN"), + ("AGENT_TOKEN", "OPENSYMPHONY_MEMORY_TOKEN"), + ] { + let mut config = profile("complete"); + config.env_refs.insert(target.into(), source.into()); + let mut launch = context(root.path()); + launch + .environment + .insert("AGENT_TOKEN".into(), "unscoped-bearer".into()); + launch + .environment + .insert("OPENSYMPHONY_MEMORY_TOKEN".into(), "managed-grant".into()); + let result = run_turn( + &config, + launch, + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await; + assert!(matches!(result, Err(ClientError::InvalidConfiguration(_)))); + } +} + #[tokio::test] async fn acp_live_input_queue_bounds_bytes_and_releases_dispatched_charges() { let root = tempfile::tempdir().expect("temp"); @@ -1068,6 +1170,21 @@ async fn acp_env_reference_sources_are_retained_only_when_explicitly_targeted() } } +#[test] +fn acp_adapter_exposes_execution_and_explicit_gaps() { + use opensymphony::opensymphony_domain::HarnessAdapter; + let adapter = opensymphony::opensymphony_acp::AcpAdapter; + assert_eq!(adapter.harness_kind(), "acp"); + let capability = adapter.capabilities(); + assert!( + capability.available + && capability.actions.start_run + && capability.cancellation.acknowledges_cancel + ); + assert!(!capability.actions.approve && !capability.pause_resume.resume); + assert!(!capability.feature_gaps.is_empty()); +} + fn services_profile(mode: &str) -> AcpProfile { let mut profile = profile(mode); profile.args[0] = format!( diff --git a/tests/acp_session_host.rs b/tests/acp_session_host.rs index 1bd29ab5a..fcdc956d4 100644 --- a/tests/acp_session_host.rs +++ b/tests/acp_session_host.rs @@ -6,6 +6,47 @@ use std::{ }; use tokio_util::sync::CancellationToken; +#[test] +fn retained_source_history_distinguishes_old_eviction_from_current_loss() { + let source = |sequence| SessionEvent::Source { + generation: 7, + run_id: "current-run".into(), + replay: false, + frame: SourceFrame { + sequence, + direction: "incoming".into(), + observed_at: chrono::Utc::now(), + payload: serde_json::json!({"method":"session/update"}), + }, + }; + let history = SourceHistory { + events: (129..=140).map(source).collect(), + truncated: true, + latest_cursor: Some((7, 140)), + }; + assert!( + history.covers_since((7, 128)), + "ancient eviction is harmless" + ); + assert!( + history.covers_since((7, 130)), + "processed frames may remain in history" + ); + assert!( + !history.covers_since((7, 127)), + "missing current frame must fence" + ); + + let oversized_tail = SourceHistory { + latest_cursor: Some((7, 141)), + ..history + }; + assert!( + !oversized_tail.covers_since((7, 140)), + "a dropped oversized frame must fence even without later frames" + ); +} + async fn launch(root: &Path, issue: &str, mode: &str) -> SessionLaunch { let root = root.canonicalize().expect("root"); let manager = WorkspaceManager::new(WorkspaceManagerConfig { @@ -73,6 +114,7 @@ async fn launch(root: &Path, issue: &str, mode: &str) -> SessionLaunch { ..ClientLimits::default() }, require_persistence: false, + expected_session_id: None, } } async fn retire(handle: &SessionHandle) { @@ -88,6 +130,26 @@ async fn prompt(handle: &SessionHandle, run: &str, text: &str) -> TurnReport { .expect("prompt") } +#[tokio::test] +async fn future_stop_reason_is_durable_terminal_evidence() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(launch(root.path(), "ISSUE-1", "none").await) + .await + .expect("open"); + let report = prompt(&handle, "future-run", "unknown-stop").await; + assert_eq!(report.stop_reason, "future_stop_reason"); + assert!(!report.succeeded()); + let snapshot = handle.inspect().await.expect("inspect"); + assert_eq!(snapshot.state.status, AcpSessionStatus::Finished); + assert_eq!( + snapshot.state.stop_reason.as_deref(), + Some("future_stop_reason") + ); + retire(&handle).await; +} + #[tokio::test] async fn retained_owner_reuses_one_process_across_attempts_and_releasing_subscribers() { let root = tempfile::tempdir().expect("temp"); @@ -171,6 +233,57 @@ async fn concurrent_sessions_busy_prompt_fence_and_cancellation_keep_other_comma retire(&b).await; } +#[tokio::test(flavor = "current_thread")] +async fn retained_turn_without_client_deadline_survives_five_minutes_of_virtual_time() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "LONG", "none").await; + request.limits.prompt_timeout = Duration::ZERO; + let handle = host.open(request).await.expect("open"); + let cancellation = CancellationToken::new(); + let turn = tokio::spawn({ + let handle = handle.clone(); + let cancellation = cancellation.clone(); + async move { + handle + .prompt("long-run".into(), 1, "hang".into(), cancellation) + .await + } + }); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if handle + .source_history() + .events + .iter() + .any(|event| matches!(event, SessionEvent::Source { frame, .. } if frame.payload["method"] == "session/prompt")) + { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("prompt submitted"); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(301)).await; + assert!( + !turn.is_finished(), + "retained turn must not hit a fixed client deadline" + ); + tokio::time::resume(); + cancellation.cancel(); + assert!( + tokio::time::timeout(Duration::from_secs(5), turn) + .await + .expect("cancelled turn completes") + .expect("turn task") + .expect("cancelled prompt") + .cancellation_acknowledged + ); + retire(&handle).await; +} + #[test] fn cancellation_while_submission_sync_is_blocked_never_sends_prompt() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/tests/action_handler.rs b/tests/action_handler.rs index 0a2bd9d4e..92caae34c 100644 --- a/tests/action_handler.rs +++ b/tests/action_handler.rs @@ -45,6 +45,7 @@ fn fixture_snapshot( total_cost_micros: 0, }, issues: vec![IssueSnapshot { + harness_capability: None, identifier: identifier.to_owned(), title: "Test Issue".to_owned(), tracker_state: "In Progress".to_owned(), diff --git a/tests/fixtures/acp_session_peer.py b/tests/fixtures/acp_session_peer.py index 381b22513..29621bb72 100644 --- a/tests/fixtures/acp_session_peer.py +++ b/tests/fixtures/acp_session_peer.py @@ -186,6 +186,9 @@ def callback(method, params, error=False): send({'method': 'session/update', 'params': {'sessionId': session, 'update': {'sessionUpdate': 'config_option_update', 'configOptions': config()}}}) if text == "crash": sys.exit(7) + if text == "unknown-stop": + send({"id": message["id"], "result": {"stopReason": "future_stop_reason", "usage": {"inputTokens": 3}}}) + continue if text == "hang": pending = message["id"] update("pending") diff --git a/tests/fixtures/acp_worker_peer.py b/tests/fixtures/acp_worker_peer.py new file mode 100644 index 000000000..d2997697d --- /dev/null +++ b/tests/fixtures/acp_worker_peer.py @@ -0,0 +1,120 @@ +"""Actual CLI ACP peer: asserts cwd, durable submission and records scoped launch inputs.""" +import json +import os +import sys +import time +import uuid + +profile = sys.argv[1] +session = "session-" + profile +pending = None +selected_model = None +session_mcp = [] +prompt_count = 0 + +def send(value): + print(json.dumps({"jsonrpc": "2.0", **value}), flush=True) + +def request(method, params): + send({"id": method, "method": method, "params": {"sessionId": session, **params}}) + response = json.loads(sys.stdin.readline()) + assert response["id"] == method and "result" in response, response + return response["result"] + +for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + if method == "initialize": + if profile == "setup_retry" and not os.path.exists(".opensymphony/setup-failed"): + open(".opensymphony/setup-failed", "w").close() + sys.exit(8) + if profile == "configured": + caps = message["params"]["clientCapabilities"] + assert caps["fs"]["readTextFile"] and caps["fs"]["writeTextFile"] and caps["terminal"] + send({"id": message["id"], "result": {"protocolVersion": 1, "agentCapabilities": {"loadSession": True, "mcpCapabilities": {"http": True}}}}) + elif method in ("session/new", "session/load"): + assert message["params"]["cwd"] == os.getcwd() + if profile == "parent_unique": + session = "session-" + uuid.uuid4().hex if method == "session/new" else message["params"]["sessionId"] + with open("acp-session-methods.jsonl", "a") as output: + output.write(json.dumps({"method": method, "session": session}) + "\n") + session_mcp = message["params"].get("mcpServers", []) + if os.environ.get("OPENSYMPHONY_MEMORY_ENDPOINT"): + assert len(session_mcp) == 1 and session_mcp[0]["type"] == "http" + assert session_mcp[0]["name"] == "opensymphony-memory" + assert session_mcp[0]["url"] == os.environ["OPENSYMPHONY_MEMORY_ENDPOINT"] + if token := os.environ.get("OPENSYMPHONY_MEMORY_TOKEN"): + assert session_mcp[0]["headers"] == [{"name": "Authorization", "value": "Bearer " + token}] + if profile in ("configured", "slow_model_hang", "unprompted_retry"): + options = [{"id": "pick", "category": "model", "name": "Model", "type": "select", "currentValue": selected_model or "profile-model", "options": [{"value": value, "name": value} for value in ("profile-model", "route-model", "other-model")]}] + send({"id": message["id"], "result": {"sessionId": session, "configOptions": options} if method == "session/new" else {"configOptions": options}}) + else: + send({"id": message["id"], "result": {"sessionId": session} if method == "session/new" else {}}) + elif method == "session/set_config_option": + assert profile in ("configured", "slow_model_hang", "unprompted_retry") and message["params"]["configId"] == "pick" + if profile == "unprompted_retry": + if not os.path.exists(".opensymphony/first-configured"): + open(".opensymphony/first-configured", "w").close() + elif not os.path.exists(".opensymphony/setup-failed"): + open(".opensymphony/setup-failed", "w").close() + send({"id": message["id"], "error": {"code": -32602, "message": "first turn setup rejected before prompt"}}) + continue + if profile == "slow_model_hang" and prompt_count: + open("acp-config-waiting", "w").close() + while not os.path.exists("acp-config-release"): + time.sleep(0.01) + selected_model = message["params"]["value"] + assert selected_model in ("profile-model", "route-model", "other-model") + options = [{"id": "pick", "category": "model", "name": "Model", "type": "select", "currentValue": selected_model, "options": [{"value": value, "name": value} for value in ("profile-model", "route-model", "other-model")]}] + send({"id": message["id"], "result": {"configOptions": options}}) + elif method == "session/prompt": + prompt_count += 1 + with open(".opensymphony/conversation.json") as source: + assert json.load(source)["acp"]["status"] == "submitted" + callback_roundtrip = False + callback_admin_present = False + if profile == "configured": + assert selected_model is not None + path = os.path.join(os.getcwd(), "acp-callback.txt") + request("fs/write_text_file", {"path": path, "content": "configured worker"}) + assert request("fs/read_text_file", {"path": path})["content"] == "configured worker" + terminal = request("terminal/create", {"command": sys.executable, "args": ["-c", "import os; print('configured terminal'); print('admin-present' if os.environ.get('OPENSYMPHONY_MEMORY_ADMIN_TOKEN') else 'admin-absent')"]})["terminalId"] + assert request("terminal/wait_for_exit", {"terminalId": terminal})["exitCode"] == 0 + terminal_output = request("terminal/output", {"terminalId": terminal})["output"] + assert "configured terminal" in terminal_output + callback_admin_present = "admin-present" in terminal_output + request("terminal/release", {"terminalId": terminal}) + callback_roundtrip = True + with open("acp-worker.json", "w") as output: + json.dump({"cwd": os.getcwd(), "profile": profile, "prompt": message["params"]["prompt"][0]["text"], "memory_project": os.environ.get("OPENSYMPHONY_MEMORY_PROJECT"), "memory_repo": os.environ.get("OPENSYMPHONY_MEMORY_EXECUTION_REPO"), "checkout_secret_present": "OPENSYMPHONY_CHECKOUT_TEST_ONLY" in os.environ, "memory_token_present": bool(os.environ.get("OPENSYMPHONY_MEMORY_TOKEN")), "memory_endpoint_present": bool(os.environ.get("OPENSYMPHONY_MEMORY_ENDPOINT")), "memory_admin_present": bool(os.environ.get("OPENSYMPHONY_MEMORY_ADMIN_TOKEN")), "memory_mcp_attached": bool(session_mcp), "selected_model": selected_model, "callback_roundtrip": callback_roundtrip, "callback_admin_present": callback_admin_present}, output) + send({"method": "session/update", "params": {"sessionId": session, "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "ACP fixture completed"}}}}) + send({"method": "session/update", "params": {"sessionId": session, "update": {"sessionUpdate": "plan", "entries": []}}}) + send({"method": "session/update", "params": {"sessionId": session, "update": {"sessionUpdate": "usage_update", "used": 42, "size": 100}}}) + with open("acp-prompts.jsonl", "a") as output: + output.write(json.dumps({"profile": profile, "prompt": message["params"]["prompt"][0]["text"]}) + "\n") + if profile == "corrupt": + os.unlink(".opensymphony/conversation.json") + os.symlink("/dev/null", ".opensymphony/conversation.json") + sys.exit(7) + if profile == "crash": + sys.exit(7) + if profile == "permission": + pending = message["id"] + send({"id": "permission", "method": "session/request_permission", "params": {"sessionId": session, "toolCall": {"toolCallId": "t", "title": "Permission"}, "options": []}}) + continue + if profile in ("hang", "slow_model_hang"): + pending = message["id"] + continue + stop_reason = ( + "future_stop_reason" if profile == "future_stop" else + "vendor_error_" + os.environ["AUDIT_TOKEN"] if profile == "secret_stop" else + "end_turn" + ) + send({"id": message["id"], "result": {"stopReason": stop_reason, "usage": {"inputTokens": 4, "outputTokens": 2, "totalTokens": 6}}}) + elif method is None and message.get("id") == "permission": + assert message["result"]["outcome"]["outcome"] == "cancelled" + send({"id": pending, "result": {"stopReason": "cancelled"}}) + elif method == "session/cancel": + send({"id": pending, "result": {"stopReason": "cancelled"}}) + else: + raise AssertionError(method) diff --git a/tests/task_graph_mutations_routes.rs b/tests/task_graph_mutations_routes.rs index 419253d01..07c6344c1 100644 --- a/tests/task_graph_mutations_routes.rs +++ b/tests/task_graph_mutations_routes.rs @@ -73,6 +73,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + harness_capability: None, identifier: "COE-405".to_owned(), title: "Linear Milestone, Issue, And Sub-Issue Mutations".to_owned(), tracker_state: "In Progress".to_owned(), From cea1b9f26cd6383c444cb39cc59bebc2b738f986 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Wed, 23 Sep 2026 07:40:25 -0500 Subject: [PATCH 18/29] docs(memory): sync COE-611 completion evidence --- .opensymphony/memory/memory.yaml | 19 +++++++++++++++++++ docs/accessibility.md | 2 ++ docs/acp.md | 3 +++ docs/architecture.md | 2 ++ docs/build.md | 2 ++ docs/cli.md | 2 ++ docs/code-intelligence.md | 2 ++ docs/code-review.md | 2 ++ docs/codex-runtime.md | 2 ++ docs/codex.md | 2 ++ docs/configuration.md | 2 ++ docs/control.md | 2 ++ docs/data-contracts.md | 2 ++ docs/debugging.md | 2 ++ docs/desktop.md | 2 ++ docs/developer-experience.md | 2 ++ docs/docs.md | 2 ++ docs/documentation.md | 2 ++ docs/duckdb.md | 2 ++ docs/evidence.md | 2 ++ docs/experiments.md | 2 ++ docs/frontend.md | 2 ++ docs/gateway.md | 2 ++ docs/graph-view.md | 2 ++ docs/harness-runtime.md | 2 ++ docs/harness.md | 2 ++ docs/installer.md | 2 ++ docs/linear.md | 2 ++ docs/mcp.md | 2 ++ docs/memory.md | 2 ++ docs/migration.md | 2 ++ docs/model-configuration.md | 2 ++ docs/okf.md | 2 ++ docs/openhands-runtime.md | 2 ++ docs/operations.md | 2 ++ docs/orchestrator.md | 2 ++ docs/outcomes.md | 2 ++ docs/planning.md | 2 ++ docs/process-protocol.md | 2 ++ docs/provenance.md | 2 ++ docs/provider.md | 3 +++ docs/quality.md | 2 ++ docs/query-packs.md | 2 ++ docs/recovery.md | 3 +++ docs/release.md | 2 ++ docs/review.md | 3 +++ docs/run-detail.md | 2 ++ docs/runner.md | 2 ++ docs/runtime.md | 2 ++ docs/rust.md | 2 ++ docs/scheduler.md | 2 ++ docs/security.md | 2 ++ docs/sources.md | 2 ++ docs/target-repo-bootstrap.md | 2 ++ docs/task-packets.md | 2 ++ docs/template.md | 2 ++ docs/testing-and-operations.md | 2 ++ docs/testing.md | 2 ++ docs/tests.md | 2 ++ docs/threejs.md | 2 ++ docs/tui.md | 2 ++ docs/ui.md | 2 ++ docs/verifiers.md | 2 ++ docs/web.md | 2 ++ docs/workflow.md | 2 ++ docs/workspace-and-lifecycle.md | 2 ++ docs/workspace-lifecycle.md | 2 ++ docs/workspace.md | 2 ++ 68 files changed, 157 insertions(+) diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index 35bd771a6..720ef62bc 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -104,7 +104,9 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration + - area:orchestrator - area:runtime - area:workspace linear_milestones: @@ -880,6 +882,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:data-contracts - area:evidence @@ -1502,6 +1505,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:evidence - area:experiments @@ -1539,6 +1543,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:experiments - area:harness @@ -1810,6 +1815,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -2963,6 +2969,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -3084,6 +3091,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -3112,6 +3120,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -3140,6 +3149,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:orchestrator - area:provider @@ -3162,6 +3172,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:evidence - area:experiments @@ -3275,7 +3286,9 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration + - area:orchestrator - area:recovery - area:runtime - area:workspace @@ -3389,6 +3402,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:orchestrator - area:recovery @@ -3486,6 +3500,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -3681,6 +3696,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -3912,6 +3928,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -4487,6 +4504,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory @@ -4680,6 +4698,7 @@ areas: source_refs: linear_labels: - area:acp + - area:cli - area:configuration - area:harness - area:memory diff --git a/docs/accessibility.md b/docs/accessibility.md index 1aff16bc3..007dcb677 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/acp.md b/docs/acp.md index 84184c401..fb38b7b6a 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -14,6 +14,7 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) - COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) +- COE-611 contributed: PR #245: COE-611: Route production workers through ACP profiles (merge `80e5ac3`) ## Important invariants @@ -33,11 +34,13 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/architecture.md b/docs/architecture.md index b3a766a4e..9644e8797 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -685,6 +685,7 @@ and updates are committed in SDK dispatch order before prompt completion. No Ope - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -823,5 +824,6 @@ and updates are committed in SDK dispatch order before prompt completion. No Ope - COE-556 - COE-609 - COE-610 +- COE-611 diff --git a/docs/build.md b/docs/build.md index e922a2d28..9e5481fc4 100644 --- a/docs/build.md +++ b/docs/build.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -236,5 +237,6 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/cli.md b/docs/cli.md index 118134080..132e991e3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -130,6 +130,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -230,5 +231,6 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index 40b76a872..9abc9c1cc 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -286,6 +286,7 @@ references, and never returns source snippets. - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -348,5 +349,6 @@ references, and never returns source snippets. - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/code-review.md b/docs/code-review.md index bf40d9f8f..d4027371f 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index 76cd7743f..5975e0995 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/codex.md b/docs/codex.md index 5fd3a7198..98d6e7443 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -234,5 +235,6 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/configuration.md b/docs/configuration.md index 273302373..7451f25a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1254,6 +1254,7 @@ dependent options. - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -1414,5 +1415,6 @@ dependent options. - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/control.md b/docs/control.md index 3845d93ed..69a04c4e5 100644 --- a/docs/control.md +++ b/docs/control.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -234,5 +235,6 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 16408f90e..b1f5f49d4 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -46,6 +46,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -62,5 +63,6 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/debugging.md b/docs/debugging.md index 2a2248580..db53e29c4 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/desktop.md b/docs/desktop.md index 5bd9e54ab..965b34591 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -238,6 +239,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 - PR-196 - PR-197 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index 96d3fe3da..0f8c3e1eb 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/docs.md b/docs/docs.md index 5b88946e5..c7998f340 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration - PR-197: Graph visualization command center and shared fixtures ## Source refs @@ -237,6 +238,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 - PR-197 diff --git a/docs/documentation.md b/docs/documentation.md index 00007bbbc..b9765a9f2 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -158,5 +159,6 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/duckdb.md b/docs/duckdb.md index c195d470a..6ad63e362 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/evidence.md b/docs/evidence.md index c108fc50f..fec35816a 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -46,6 +46,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -62,5 +63,6 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/experiments.md b/docs/experiments.md index 98206d5e0..21486639a 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/frontend.md b/docs/frontend.md index 8900c1d14..49d1c689c 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -158,6 +159,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 - PR-196 - PR-197 diff --git a/docs/gateway.md b/docs/gateway.md index a1d729d85..6c92b9c63 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -154,6 +154,7 @@ baseline, not an issue-workspace overlay. - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration - PR-196: Desktop task and diff latency plus live-refresh UX fixes ## Source refs @@ -258,6 +259,7 @@ baseline, not an issue-workspace overlay. - COE-608 - COE-609 - COE-610 +- COE-611 - PR-196 diff --git a/docs/graph-view.md b/docs/graph-view.md index 300b4350d..27db2ad27 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -156,6 +157,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 - PR-196 - PR-197 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index 4a68eb3da..f0c74ebf0 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/harness.md b/docs/harness.md index e77bd2e9d..956c9c0b4 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -40,6 +40,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -50,5 +51,6 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/installer.md b/docs/installer.md index 4c5bc3888..37beb8891 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/linear.md b/docs/linear.md index ce2fab1e9..4ceddf916 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -236,5 +237,6 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/mcp.md b/docs/mcp.md index 7016e3bb8..631e79470 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/memory.md b/docs/memory.md index ecafb0c06..970ab41c7 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -846,6 +846,7 @@ are covered by the same archive operation. - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -1014,5 +1015,6 @@ are covered by the same archive operation. - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/migration.md b/docs/migration.md index 2fffe452e..8240b3f8c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index d8f95f827..2e567db65 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/okf.md b/docs/okf.md index 946bf0be6..7ea90fa38 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index bdced01a3..eb6b2541c 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/operations.md b/docs/operations.md index 602969115..69e506727 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1231,6 +1231,7 @@ before cancellation. - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -1402,5 +1403,6 @@ before cancellation. - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index 3bde41f84..ecd2f7dae 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -234,5 +235,6 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/outcomes.md b/docs/outcomes.md index 2b4d01ac3..86a58279f 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/planning.md b/docs/planning.md index dcd80f33e..8e7dd0cf3 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index f185397c3..5e29b6aaf 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/provenance.md b/docs/provenance.md index b20150632..b104ae35f 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/provider.md b/docs/provider.md index 144e27fef..704b40025 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) - COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) +- COE-611 contributed: PR #245: COE-611: Route production workers through ACP profiles (merge `80e5ac3`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/quality.md b/docs/quality.md index 4894032bd..ff3851176 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -46,6 +46,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -62,5 +63,6 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/query-packs.md b/docs/query-packs.md index ffde8fb7d..b2ee624ac 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/recovery.md b/docs/recovery.md index 879f888b1..192e53bf1 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -15,6 +15,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) - COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) +- COE-611 contributed: PR #245: COE-611: Route production workers through ACP profiles (merge `80e5ac3`) ## Important invariants @@ -35,6 +36,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -42,5 +44,6 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/release.md b/docs/release.md index 8ee73a595..f7c2299cc 100644 --- a/docs/release.md +++ b/docs/release.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -150,6 +151,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 ## 2.11 Rust toolchain boundary diff --git a/docs/review.md b/docs/review.md index 777b88c90..b0ea00d48 100644 --- a/docs/review.md +++ b/docs/review.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-608 contributed: PR #241: feat(acp): add supervised ACP v1 client and launch profiles (merge `70619ec`) - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) - COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) +- COE-611 contributed: PR #245: COE-611: Route production workers through ACP profiles (merge `80e5ac3`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/run-detail.md b/docs/run-detail.md index a9f6ee8f6..b075989ff 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/runner.md b/docs/runner.md index 007864e03..d646935bf 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/runtime.md b/docs/runtime.md index 0e9891b24..bc975456e 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -234,5 +235,6 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/rust.md b/docs/rust.md index 1b105b87d..a58e8d1b2 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -158,5 +159,6 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/scheduler.md b/docs/scheduler.md index 355a9e8a1..76a99357a 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/security.md b/docs/security.md index f923e4bec..a21bdf046 100644 --- a/docs/security.md +++ b/docs/security.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -236,5 +237,6 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/sources.md b/docs/sources.md index 511dfa416..92957b023 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -368,6 +368,7 @@ write/delete sharing until file I/O or terminal spawn finishes. - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -452,5 +453,6 @@ write/delete sharing until file I/O or terminal spawn finishes. - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index 18530678a..c12337101 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/task-packets.md b/docs/task-packets.md index 912cb486c..79b5f1174 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/template.md b/docs/template.md index 72cb4c2b2..06ece9d7e 100644 --- a/docs/template.md +++ b/docs/template.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 4ac6fa68c..2630983d2 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -907,6 +907,7 @@ tracker suites so local OAuth configuration does not override fixture credential - COE-556: Bottom-Up Subtree Cleanup And Recovery - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -1061,5 +1062,6 @@ tracker suites so local OAuth configuration does not override fixture credential - COE-556 - COE-609 - COE-610 +- COE-611 diff --git a/docs/testing.md b/docs/testing.md index e998347be..c0063d055 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -234,6 +235,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 - PR-196 - PR-197 diff --git a/docs/tests.md b/docs/tests.md index 746da6a16..209a24293 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -158,5 +159,6 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/threejs.md b/docs/threejs.md index cfb053106..b95c0f32e 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/tui.md b/docs/tui.md index b19619303..8f4281709 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -226,5 +227,6 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/ui.md b/docs/ui.md index 3c5675b30..7ca72046c 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -128,6 +128,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -228,6 +229,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 - PR-196 - PR-197 diff --git a/docs/verifiers.md b/docs/verifiers.md index 13da3abec..92148b51b 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/web.md b/docs/web.md index 752200e7b..8705e687a 100644 --- a/docs/web.md +++ b/docs/web.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/workflow.md b/docs/workflow.md index fda5b660c..1b426f4ad 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -158,5 +159,6 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 065b67581..341d2aec5 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -1102,6 +1102,7 @@ or MCP grants. - COE-473: Desktop task graph dependency and run detail parity - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -1124,5 +1125,6 @@ or MCP grants. - COE-473 - COE-609 - COE-610 +- COE-611 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index d40174508..750e53ce3 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -89,6 +89,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -148,5 +149,6 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 diff --git a/docs/workspace.md b/docs/workspace.md index af5bce78d..521757549 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-608: ACP Profiles And Executable Protocol Client - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration +- COE-611: ACP Execution Routing And Worker Integration ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-608 - COE-609 - COE-610 +- COE-611 From a6cd1185c51e90c9e855977445fd9f5d8b0b2190 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Wed, 23 Sep 2026 14:41:08 -0500 Subject: [PATCH 19/29] Route ACP operator requests through scheduler and clients (#246) * feat(acp): route operator requests through scheduler and clients Summary: - Bind native ACP permission, question, and plan callbacks to live runs. - Route typed decisions through scheduler and gateway with stale-response fences, deadlines, receipts, and explicit permission policies. - Add web, desktop, and terminal controls plus protocol documentation. Rationale: - ACP callbacks must wait for a real, authorized operator answer without replaying stale approvals or blocking unrelated scheduler work. - Preserve opaque option and RPC identities while keeping protocol details inside the ACP adapter. Tests: - Native production worker/scheduler/gateway/peer round trip. - ACP transport 47 tests; gateway negative cases; terminal gateway client. - npm test (43 suites, 762 tests); npm run type-check. - cargo fmt --check; cargo clippy-system-duckdb; desktop cargo check. * fix(acp): preserve prompt cancellation when operator channel closes Summary: - Stop selecting closed optional operator channels during a worker turn. - Wait for the prompt result when pre-submission cancellation drops the operator request sender. - Print the full retained worker outcome on cancellation test failure. Rationale: - The closed channel could win the select race and turn an acknowledged cancellation into a failed worker outcome. Tests: - Reproduced failure on default-parallel unit run 4/4 before fix. - Four consecutive default-parallel unit suites passed after fix. - Full default-thread cargo test-system-duckdb -q passed; clippy and fmt checks passed. * fix(acp): bound and route operator callbacks safely Route standard form elicitation through the operator path, preserve RPC IDs across JavaScript, and handle gateway commands independently of tracker polling. Bound callback reservations and page terminal choices. Leave Cursor vendor methods for COE-613. * fix(acp): publish operator callbacks without tracker polling * fix(acp): keep scheduler ticks and operator forms stable * fix(acp): keep operator responses live across client boundaries Summary: - Proxy desktop capability discovery to the attached gateway and align mock approval targets with the HTTP transport. - Restart idle stall activity after delivered or closed operator requests while preserving the absolute runtime cap. - Keep Ctrl-C armed during non-cancellable scheduler ticks and cover the response, closure, shutdown, and client paths with regression tests. Rationale: - Static desktop metadata hid configured ACP profiles, and stale stall deadlines could abort a worker immediately after operator input. - A transient shutdown listener could lose a signal during a long tick. Tests: - Full Rust suite with four test threads; 1,457 unit tests and all integration groups passed. - Native Tauri capability test; 43 Jest suites and 763 tests. - TypeScript type-check, cargo format, and all-targets clippy. * fix(acp): fence timed-out operator replies and clear stale forms * fix(acp): fence gateway command timeout and serialize replies * fix: fence ACP operator delivery through peer input flush * test: publish ACP peer completion marker atomically * fix: keep ACP delivery waits off scheduler actor * fix(acp): fence automatic permission decisions by prompt epoch Summary: - Require an active callback epoch before applying automatic permission policy, including turns without an operator route. - Cover an in-turn allow and adjacent post-completion permission callback with a native retained-session peer. - Document the turn-bound permission behavior. Rationale: - A peer could ask for permission immediately after end_turn and receive allow_once because automatic answers bypassed the callback-epoch guard. - Retaining the epoch independently of the operator sender keeps automatic policies available during a live turn and cancels late requests. Tests: - cargo test-system-duckdb -q -- --test-threads=4 - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check * fix(acp): deliver callback closures under channel saturation Summary: - Wait boundedly to enqueue a callback Closed event after its response flushes, instead of dropping the event when the worker queue is full. - Fail the run if the worker stops draining the event queue, allowing worker completion to reconcile pending operator state. - Add a native peer regression that holds the prompt active while two timed-out callbacks close through a saturated event channel. Rationale: - Lost timeout closures left worker waiters and scheduler interactions pending, and skipped the idle-clock reset after an agent resumed. Tests: - cargo test-system-duckdb -q -- --test-threads=4 - 10 native saturation regression repetitions - cargo clippy-system-duckdb - cargo fmt --all -- --check - git diff --check --- apps/desktop/src-tauri/src/commands.rs | 172 +++-- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/index.ts | 10 +- apps/web/src/index.ts | 8 +- crates/opensymphony-acp/src/host.rs | 23 +- crates/opensymphony-acp/src/lib.rs | 369 +++++++++- crates/opensymphony-acp/src/operator.rs | 643 ++++++++++++++++++ crates/opensymphony-acp/src/projection.rs | 32 +- crates/opensymphony-acp/src/services.rs | 79 ++- crates/opensymphony-cli/src/lib.rs | 3 + .../src/orchestrator_run/backends.rs | 451 +++++++++++- .../src/orchestrator_run/backends/acp.rs | 102 ++- .../src/orchestrator_run/mod.rs | 517 ++++++++++++-- .../src/orchestrator_run/snapshot.rs | 7 + crates/opensymphony-cli/tests/tui.rs | 1 + crates/opensymphony-codex/src/lib.rs | 1 + crates/opensymphony-control/src/lib.rs | 1 + .../tests/control_plane.rs | 1 + .../opensymphony-domain/src/control_plane.rs | 3 + crates/opensymphony-domain/src/lib.rs | 31 + crates/opensymphony-domain/src/snapshot.rs | 4 + .../opensymphony-domain/src/state_machine.rs | 9 + .../tests/snapshot_serialization.rs | 1 + .../opensymphony-gateway-schema/src/action.rs | 7 + .../src/approval.rs | 70 ++ .../src/capability.rs | 8 +- .../tests/gateway_schema.rs | 9 +- .../src/action_handler.rs | 15 +- crates/opensymphony-gateway/src/lib.rs | 406 ++++++++++- crates/opensymphony-gateway/tests/gateway.rs | 293 ++++++++ crates/opensymphony-orchestrator/src/lib.rs | 8 +- .../src/scheduler.rs | 317 ++++++++- .../tests/scheduler.rs | 273 +++++++- crates/opensymphony-tui/src/lib.rs | 586 +++++++++++++++- crates/opensymphony-tui/tests/reducer.rs | 2 + crates/opensymphony-workflow/src/acp.rs | 19 + crates/opensymphony-workflow/src/lib.rs | 4 +- docs/architecture.md | 22 +- docs/configuration.md | 15 +- docs/harness-adapter-compatibility.md | 18 +- docs/operations.md | 45 ++ docs/specs/acp-harness-adapter.md | 12 +- docs/testing-and-operations.md | 41 +- .../api-client/__tests__/run-actions.test.ts | 15 + packages/api-client/src/index.ts | 6 +- packages/api-client/src/mock.ts | 20 +- packages/api-client/src/transports.ts | 42 +- packages/gateway-schema/src/action.ts | 2 + packages/gateway-schema/src/approval.ts | 22 + packages/gateway-schema/src/index.ts | 4 + packages/ui-core/__tests__/app-shell.test.ts | 75 ++ packages/ui-core/__tests__/approval.test.ts | 32 +- packages/ui-core/src/app-shell.ts | 136 +++- packages/ui-core/src/approval.ts | 32 +- tests/acp.rs | 51 +- tests/acp_session_host.rs | 258 +++++++ tests/action_handler.rs | 1 + tests/fixtures/acp_peer.py | 10 + tests/fixtures/acp_services_peer.py | 2 +- tests/fixtures/acp_session_peer.py | 67 +- tests/fixtures/acp_worker_peer.py | 21 + tests/task_graph_mutations_routes.rs | 1 + 62 files changed, 5153 insertions(+), 283 deletions(-) create mode 100644 crates/opensymphony-acp/src/operator.rs diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 24bc6c189..f2b9e5786 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -825,7 +825,7 @@ async fn update_gateway_connection( } async fn set_gateway_connected_for_url( - state: &tauri::State<'_, RwLock>, + state: &RwLock, base_url: &str, connected: bool, ) { @@ -884,6 +884,14 @@ async fn gateway_request_json( state: tauri::State<'_, RwLock>, method: reqwest::Method, path: &str, +) -> CommandResult { + gateway_request_json_from_connection(&state, method, path).await +} + +async fn gateway_request_json_from_connection( + state: &RwLock, + method: reqwest::Method, + path: &str, ) -> CommandResult { let (base_url, auth_token, client) = { let conn = state.read().await; @@ -1379,6 +1387,19 @@ pub async fn run_approvals( .await } +/// Get structured operator input requests. +#[command] +pub async fn run_inputs( + state: tauri::State<'_, RwLock>, + run_id: String, +) -> CommandResult { + gateway_get_json( + state, + &format!("/api/v1/runs/{}/inputs", urlencoding::encode(&run_id)), + ) + .await +} + /// Get run events with cursor support. #[command] pub async fn run_events( @@ -1477,12 +1498,7 @@ fn profile_is_current_gateway_available( // ─── Gateway Local Stream Transport (COE-410) ────────────────────────────── use crate::opensymphony_gateway_schema::{ - capability::{ - AuthMode, FeatureCapability as GatewayFeatureCapability, GatewayCapabilities, - HarnessCapability, TransportCapability as GatewayTransportCapability, - }, - envelope::GatewayEnvelope, - version::SchemaVersion, + capability::GatewayCapabilities, envelope::GatewayEnvelope, }; /// Request to subscribe to the gateway event stream via Tauri channel. @@ -1525,48 +1541,20 @@ pub struct GatewayConnectionInfo { /// Query gateway capabilities. /// Used by the frontend transport factory to select the optimal profile. #[command] -pub async fn gateway_capabilities() -> CommandResult { - // Desktop exposes the same loopback gateway capability truth consumed by - // the frontend. Harness execution still routes through the gateway/runtime, - // not a separate desktop-side Codex launcher. - Ok(GatewayCapabilities { - schema_version: SchemaVersion::v1(), - gateway_version: env!("CARGO_PKG_VERSION").to_string(), - supported_api_versions: vec!["1.0.0".to_string()], - transports: vec![GatewayTransportCapability { - transport: "loopback_http".to_string(), - modes: vec!["json".to_string()], - supported_encodings: vec!["utf-8".to_string()], - bidirectional: false, - }], - harnesses: vec![ - HarnessCapability::openhands_agent_server(), - HarnessCapability::codex_app_server_local(), - HarnessCapability::rust_native_future(), - ], - features: vec![ - GatewayFeatureCapability { - feature: "task_graph".to_string(), - available: true, - requires_auth: false, - requires_plan: None, - }, - GatewayFeatureCapability { - feature: "terminal_stream".to_string(), - available: false, - requires_auth: false, - requires_plan: None, - }, - GatewayFeatureCapability { - feature: "tauri_channel".to_string(), - available: false, - requires_auth: false, - requires_plan: None, - }, - ], - auth_modes: vec![AuthMode::None, AuthMode::ApiKey], - max_event_page_size: 1000, - max_terminal_frame_batch: 500, +pub async fn gateway_capabilities( + state: tauri::State<'_, RwLock>, +) -> CommandResult { + gateway_capabilities_from_connection(&state).await +} + +async fn gateway_capabilities_from_connection( + state: &RwLock, +) -> CommandResult { + let value = + gateway_request_json_from_connection(state, reqwest::Method::GET, "/api/v1/capabilities") + .await?; + serde_json::from_value(value).map_err(|error| DesktopError::Gateway { + message: format!("Invalid gateway capabilities: {error}"), }) } @@ -1767,30 +1755,68 @@ mod tests { } #[tokio::test] - async fn gateway_capabilities_advertises_http_only_native_transport() { - let capabilities = gateway_capabilities().await.unwrap(); - let transports: Vec<&str> = capabilities - .transports - .iter() - .map(|transport| transport.transport.as_str()) - .collect(); - - assert_eq!(transports, vec!["loopback_http"]); - - let harnesses: Vec<(&str, bool)> = capabilities - .harnesses - .iter() - .map(|harness| (harness.kind.as_str(), harness.available)) - .collect(); - - assert_eq!( - harnesses, - vec![ - ("openhands_agent_server", true), - ("codex_app_server", true), - ("rust_native", false), - ] - ); + async fn gateway_capabilities_reflects_attached_gateway_acp_profile() { + use crate::opensymphony_gateway_schema::{ + capability::{ + AuthMode, HarnessCapability, HarnessProfileCapability, TransportCapability, + }, + version::SchemaVersion, + }; + use std::io::{BufRead, BufReader, Write}; + + let advertised = GatewayCapabilities { + schema_version: SchemaVersion::v1(), + gateway_version: "configured-gateway".into(), + supported_api_versions: vec!["1.0.0".into()], + transports: vec![TransportCapability { + transport: "loopback_http".into(), + modes: vec!["json".into()], + supported_encodings: vec!["utf-8".into()], + bidirectional: false, + }], + harnesses: vec![HarnessCapability::acp()], + harness_profiles: vec![HarnessProfileCapability { + harness: "acp".into(), + profile_id: "local-acp".into(), + preflight_ready: true, + unavailable_reason: None, + }], + features: Vec::new(), + auth_modes: vec![AuthMode::None], + max_event_page_size: 1000, + max_terminal_frame_batch: 500, + }; + let body = serde_json::to_string(&advertised).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request = String::new(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(!line.is_empty(), "gateway request closed before headers"); + if line == "\r\n" { + break; + } + request.push_str(&line); + } + assert!(request.starts_with("GET /api/v1/capabilities ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer test-token")); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + }); + let state = RwLock::new(GatewayConnection { + base_url, + auth_token: Some("test-token".into()), + ..GatewayConnection::default() + }); + let actual = gateway_capabilities_from_connection(&state).await.unwrap(); + server.join().unwrap(); + assert_eq!(actual, advertised); + assert!(state.read().await.connected); } #[test] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9c8aa68c0..fe1a127eb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -82,6 +82,7 @@ pub fn run() { commands::run_diffs, commands::run_validation, commands::run_approvals, + commands::run_inputs, commands::run_events, commands::terminal_snapshot, commands::get_connection_profiles, diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts index 89713004e..e58f71a3d 100644 --- a/apps/desktop/src/index.ts +++ b/apps/desktop/src/index.ts @@ -310,6 +310,12 @@ class DesktopTransportAdapter implements TauriTransportAdapter { ).then((response) => response.approvals ?? []); } + runInputs(runId: string): ReturnType { + return this.invokeOrHttp<{ inputs?: Awaited> }>( + "run_inputs", { runId }, async () => ({ inputs: await this.inner.runInputs(runId) }), + ).then((response) => response.inputs ?? []); + } + runValidation(runId: string): ReturnType { return this.invokeOrHttp("run_validation", { runId }, () => this.inner.runValidation(runId)); } @@ -370,8 +376,10 @@ class DesktopTransportAdapter implements TauriTransportAdapter { approvalId: string, decision: "approved" | "rejected", explanation?: string, + interaction?: import("@opensymphony/gateway-schema").OperatorInteraction, + optionId?: string, ): Promise { - return this.actionInner.approvalDecision(approvalId, decision, explanation); + return this.actionInner.approvalDecision(approvalId, decision, explanation, interaction, optionId); } openWorkspace(runId: string): Promise { diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 2c6140c2e..19f143ff8 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -87,6 +87,10 @@ class BrowserTransport implements BrowserTransportAdapter { return this.inner.runApprovals(runId); } + runInputs(runId: string): ReturnType { + return this.inner.runInputs(runId); + } + runValidation(runId: string): ReturnType { return this.inner.runValidation(runId); } @@ -147,8 +151,10 @@ class BrowserTransport implements BrowserTransportAdapter { approvalId: string, decision: "approved" | "rejected", explanation?: string, + interaction?: import("@opensymphony/gateway-schema").OperatorInteraction, + optionId?: string, ): Promise { - return this.inner.approvalDecision(approvalId, decision, explanation); + return this.inner.approvalDecision(approvalId, decision, explanation, interaction, optionId); } openWorkspace(runId: string): Promise { diff --git a/crates/opensymphony-acp/src/host.rs b/crates/opensymphony-acp/src/host.rs index 9408bca89..3a583a457 100644 --- a/crates/opensymphony-acp/src/host.rs +++ b/crates/opensymphony-acp/src/host.rs @@ -372,6 +372,18 @@ impl SessionHandle { attempt: u32, prompt: String, cancellation: CancellationToken, + ) -> Result { + self.prompt_with_operator(run_id, attempt, prompt, cancellation, None) + .await + } + + pub async fn prompt_with_operator( + &self, + run_id: String, + attempt: u32, + prompt: String, + cancellation: CancellationToken, + operator_requests: Option>, ) -> Result { let (reply, receive) = oneshot::channel(); self.commands @@ -381,6 +393,7 @@ impl SessionHandle { attempt, prompt, cancellation, + operator_requests, reply, }) .map_err(channel_error)?; @@ -423,6 +436,7 @@ enum Command { attempt: u32, prompt: String, cancellation: CancellationToken, + operator_requests: Option>, reply: oneshot::Sender>, }, } @@ -662,6 +676,7 @@ async fn start( idle_since: tokio::time::Instant::now(), retire_reply: None, reset_reason: None, + operator_router: Arc::new(Mutex::new(None)), }; tokio::spawn(async move { let result = super::run_connection( @@ -753,6 +768,7 @@ struct PreparingPrompt { cancellation: CancellationToken, forced: CancellationToken, callback_epoch: CancellationToken, + operator_requests: Option>, reply: oneshot::Sender>, } @@ -767,6 +783,7 @@ pub(super) struct SessionDriver { idle_since: tokio::time::Instant, retire_reply: Option>>, reset_reason: Option, + pub(super) operator_router: super::operator::OperatorRouter, } impl SessionDriver { pub(super) async fn launched(&mut self, pid: Option) -> Result<(), ClientError> { @@ -943,6 +960,8 @@ impl SessionDriver { let _ = pending.reply.send(Err(HostError::Client(error.to_string()))); return Err(error); } + *self.operator_router.lock().unwrap_or_else(|e| e.into_inner()) = + Some((pending.operator_requests, pending.callback_epoch.clone())); active = Some(ActivePrompt { result: Box::pin(prompt_rpc(connection.clone(), initialization.clone(), session_id.clone(), pending.prompt, pending.cancellation, pending.forced.clone(), capture.clone(), limits.clone(), configuration.clone(), pending.callback_epoch, services.clone())), reply: pending.reply, cancellation: pending.forced, @@ -980,7 +999,7 @@ impl SessionDriver { continue; }; match command { - Command::Prompt { generation, run_id, attempt, prompt, cancellation, reply } => { + Command::Prompt { generation, run_id, attempt, prompt, cancellation, operator_requests, reply } => { if generation != self.durable.state().identity.generation { let _ = reply.send(Err(HostError::IdentityMismatch)); continue; } if active.is_some() || preparing.is_some() { let _ = reply.send(Err(HostError::Busy)); continue; } if prompt.len() > limits.frame_bytes / 6 || run_id.is_empty() || run_id.len() > 1024 { let _ = reply.send(Err(HostError::ResourceLimit)); continue; } @@ -1009,7 +1028,7 @@ impl SessionDriver { }) => result.map_err(|_| ClientError::SetupTimeout)?, } }), - run_id, attempt, prompt, cancellation, forced, callback_epoch, reply, + run_id, attempt, prompt, cancellation, forced, callback_epoch, operator_requests, reply, }); self.publish(true); } diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs index 7f0de8a81..f939689dd 100644 --- a/crates/opensymphony-acp/src/lib.rs +++ b/crates/opensymphony-acp/src/lib.rs @@ -21,7 +21,6 @@ use agent_client_protocol::{ v1::{ AuthMethod, AuthenticateRequest, CancelNotification, ContentBlock, InitializeRequest, InitializeResponse, LoadSessionRequest, NewSessionRequest, PromptRequest, - RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, ResumeSessionRequest, TextContent, }, }, @@ -45,10 +44,14 @@ use tracing::instrument::WithSubscriber; mod atomic_file; mod durable; mod host; +mod operator; mod projection; mod services; mod session_config; pub use host::*; +pub use operator::{ + AcpOperatorDeliveryFence, AcpOperatorEvent, AcpOperatorReply, AcpOperatorRequest, +}; pub use projection::{RuntimeProjection, RuntimeUpdate, profile_capabilities, run_capability}; pub use services::HostServices; pub use session_config::SessionConfiguration; @@ -65,6 +68,7 @@ mod windows_path; #[cfg(windows)] mod windows_process; +use crate::opensymphony_gateway_schema::approval::OperatorAnswer; pub use crate::opensymphony_workflow::AcpProfile; use crate::opensymphony_workspace::{ environment_variable_names_equal, has_environment_name_collision, insert_environment_value, @@ -260,10 +264,23 @@ pub struct RunResult { /// Retaining the SDK's exact encoding also rejects unreserved SDK-generated responses. #[derive(Default)] struct CallbackOutput { - frames: VecDeque<(String, bool)>, + frames: VecDeque<(String, bool, Option>)>, bytes: usize, } +fn reserve_and_enqueue_operator_response( + output: &Mutex, + frame: String, + limits: &ClientLimits, + acknowledgement: &mut Option>, + enqueue: impl FnOnce() -> T, +) -> Option { + let mut output = output.lock().unwrap_or_else(|e| e.into_inner()); + output + .admit_with_ack(frame, limits, false, acknowledgement) + .then(enqueue) +} + impl CallbackOutput { fn admit(&mut self, frame: String, limits: &ClientLimits) -> bool { self.admit_with_opaque_payload(frame, limits, false) @@ -274,6 +291,16 @@ impl CallbackOutput { frame: String, limits: &ClientLimits, opaque_payload: bool, + ) -> bool { + self.admit_with_ack(frame, limits, opaque_payload, &mut None) + } + + fn admit_with_ack( + &mut self, + frame: String, + limits: &ClientLimits, + opaque_payload: bool, + acknowledgement: &mut Option>, ) -> bool { let bytes = frame.len() + 1; // LinesCodec adds LF. if frame.len() > limits.frame_bytes @@ -283,21 +310,40 @@ impl CallbackOutput { return false; } self.bytes += bytes; - self.frames.push_back((frame, opaque_payload)); + self.frames + .push_back((frame, opaque_payload, acknowledgement.take())); true } fn matches_front(&self, frame: &str) -> bool { self.frames .front() - .is_some_and(|(expected, _)| expected == frame) + .is_some_and(|(expected, _, _)| expected == frame) } fn flushed(&mut self) { - if let Some((frame, _)) = self.frames.pop_front() { + if let Some((frame, _, acknowledgement)) = self.frames.pop_front() { self.bytes -= frame.len() + 1; + if let Some(acknowledgement) = acknowledgement { + let _ = acknowledgement.send(true); + } } } + + fn fail_all(&mut self) { + while let Some((_, _, acknowledgement)) = self.frames.pop_front() { + if let Some(acknowledgement) = acknowledgement { + let _ = acknowledgement.send(false); + } + } + self.bytes = 0; + } +} + +impl Drop for CallbackOutput { + fn drop(&mut self) { + self.fail_all(); + } } #[derive(Default)] @@ -971,6 +1017,11 @@ async fn run_connection( let service_run = service_actor.run(); tokio::pin!(service_run); let configuration = Arc::new(Mutex::new(SessionConfiguration::default())); + let operator_router = driver + .as_ref() + .map(|driver| driver.operator_router.clone()) + .unwrap_or_else(|| Arc::new(Mutex::new(None))); + let permission_policy = profile.permissions.mode; let input = FramedRead::new(stdout, LinesCodec::new_with_max_length(limits.frame_bytes)); let incoming = input.map({ let queued = queued.clone(); @@ -1014,17 +1065,35 @@ async fn run_connection( async move { if line.len() > limits.frame_bytes { resource_failure.store(true, Ordering::Release); + callback_output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fail_all(); return Err(io_failure()); } - let mut value: Value = serde_json::from_str(&line).map_err(|_| io_failure())?; + let mut value: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(_) => { + callback_output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fail_all(); + return Err(io_failure()); + } + }; let is_response = value.get("method").is_none(); if is_response { let output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); if !output.matches_front(&line) { // Unreserved SDK errors cannot bypass callback admission. + drop(output); + callback_output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fail_all(); return Err(io_failure()); } - if output.frames.front().is_some_and(|(_, opaque)| *opaque) { + if output.frames.front().is_some_and(|(_, opaque, _)| *opaque) { for field in ["/result/content", "/result/output"] { if let Some(content) = value.pointer_mut(field) { *content = json!("[redacted]"); @@ -1036,7 +1105,17 @@ async fn run_connection( submitted.store(true, Ordering::Release); } capture(&capture_state, "outgoing", value); - output.send(line).await.map_err(|_| io_failure())?; + if !matches!( + tokio::time::timeout(limits.callback_timeout, output.send(line)).await, + Ok(Ok(())) + ) { + resource_failure.store(true, Ordering::Release); + callback_output + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fail_all(); + return Err(io_failure()); + } if is_response { callback_output .lock() @@ -1058,6 +1137,7 @@ async fn run_connection( let redactor = capture_state.clone(); let configuration = configuration.clone(); let service_sender = service_sender.clone(); + let operator_router = operator_router.clone(); async move |message: Dispatch, _cx| { queued .lock() @@ -1083,27 +1163,167 @@ async fn run_connection( fatal.cancel(); }); } - let response = if request.method == "session/request_permission" { - match serde_json::from_value::(request.params) - { - Ok(request) - if active_session - .lock() - .unwrap_or_else(|e| e.into_inner()) - .as_deref() - == Some(request.session_id.0.as_ref()) => - { - // No operator policy is advertised in this slice. - serde_json::to_value(RequestPermissionResponse::new( - RequestPermissionOutcome::Cancelled, - )) - .map_err(agent_client_protocol::Error::from) + let operator_method = matches!(request.method.as_str(), + "session/request_permission" | "elicitation/create"); + if operator_method { + let session = active_session.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let rpc_id = serde_json::to_value(responder.id())?; + // Reserve against the private peer ID's encoded size, not the + // public opaque interaction token shown to operators. + let private_rpc_id = rpc_id.to_string(); + let mut safe_params = request.params.clone(); + redactor.lock().unwrap_or_else(|e| e.into_inner()).redact(&mut safe_params, false); + let interaction = session.as_deref().ok_or_else(agent_client_protocol::Error::invalid_params) + .and_then(|session| operator::parse_interaction(&request.method, &safe_params, rpc_id.clone(), session, limits.callback_timeout)); + // Reject an option ID that changed during redaction; never send + // an altered opaque ID back to the peer or expose a secret. + let interaction = interaction.and_then(|safe| { + let original = operator::parse_interaction(&request.method, &request.params, rpc_id, &safe.session_id, limits.callback_timeout)?; + if !operator::same_binding_ids(&safe, &original) { + return Err(agent_client_protocol::Error::invalid_params()); + } + Ok(safe) + }); + let interaction = match interaction { + Ok(interaction) => interaction, + Err(_) => { + // A malformed or unbound callback never reaches an + // operator. Return the protocol's cancellation outcome + // so the peer can finish its current turn safely. + let response = Ok(if request.method == "elicitation/create" { + json!({"action":"cancel"}) + } else { + json!({"outcome":{"outcome":"cancelled"}}) + }); + let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; + let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if !output.admit(frame, &limits) { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + return Err(agent_client_protocol::Error::internal_error()); + } + return responder.respond_with_result(response); + } + }; + let route = operator_router.lock().unwrap_or_else(|e| e.into_inner()).clone(); + // An automatic allow/deny is still a callback decision and + // must obey the same active-turn epoch as routed requests. + // The prompt response revokes this epoch in ordered dispatch + // before an adjacent late callback can run. + let (sender, epoch) = match route { + Some((sender, epoch)) if !epoch.is_cancelled() => (sender, epoch), + _ => { + let response = Ok(operator::response_for(&interaction, OperatorAnswer::Cancel)); + let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; + let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if !output.admit(frame, &limits) { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + return Err(agent_client_protocol::Error::internal_error()); + } + return responder.respond_with_result(response); } - _ => Err(agent_client_protocol::Error::invalid_params()), + }; + if let Some(answer) = operator::automatic_answer(permission_policy, &interaction) { + let response = Ok(operator::response_for(&interaction, answer)); + let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; + let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if !output.admit(frame, &limits) { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + return Err(agent_client_protocol::Error::internal_error()); + } + return responder.respond_with_result(response); + } + let reservation = match service_sender.reserve_operator( + &request.method, + &request.params, + &private_rpc_id, + ) { + Ok(reservation) => reservation, + Err(_) => { + let response = Ok(operator::response_for(&interaction, OperatorAnswer::Cancel)); + let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; + let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if !output.admit(frame, &limits) { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + return Err(agent_client_protocol::Error::internal_error()); + } + return responder.respond_with_result(response); + } + }; + if let Some(sender) = sender { + let (reply, receive) = oneshot::channel(); + if sender.try_send(AcpOperatorEvent::Opened(Box::new(AcpOperatorRequest { interaction: interaction.clone(), reply }))).is_ok() { + let callback_output = callback_output.clone(); + let resource_failure = resource_failure.clone(); + let fatal = fatal.clone(); + let limits = limits.clone(); + tokio::spawn(async move { + let _reservation = reservation; + let (answer, reply) = tokio::select! { + biased; + _ = epoch.cancelled() => (OperatorAnswer::Cancel, None), + result = receive => result.map(|reply: AcpOperatorReply| (reply.answer.clone(), Some(reply))).unwrap_or((OperatorAnswer::Cancel, None)), + _ = tokio::time::sleep(limits.callback_timeout) => (OperatorAnswer::Cancel, None), + }; + let (answer, mut acknowledgement) = match reply { + Some(reply) if reply.delivery.claim() => (answer, Some(reply.acknowledgement)), + Some(reply) => { + let _ = reply.acknowledgement.send(false); + (OperatorAnswer::Cancel, None) + } + None => (answer, None), + }; + let response = Ok(operator::response_for(&interaction, answer)); + let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone())); + let delivered = frame.ok().and_then(|frame| { + reserve_and_enqueue_operator_response(&callback_output, frame, &limits, &mut acknowledgement, || responder.respond_with_result(response)) + }); + let Some(delivered) = delivered else { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + if let Some(acknowledgement) = acknowledgement { let _ = acknowledgement.send(false); } + return; + }; + if delivered.is_err() { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + callback_output.lock().unwrap_or_else(|e| e.into_inner()).fail_all(); + return; + } + // The worker may be processing a burst of Opened + // events while callbacks expire. A full channel must + // not discard the only closure for an accepted + // interaction; bound the wait and fail the turn if + // the worker stops draining it. + if tokio::time::timeout( + limits.cancel_timeout, + sender.send(AcpOperatorEvent::Closed(interaction.request_id)), + ).await.is_err() { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + } + }); + return Ok(()); + } + } + // A permission with no operator route fails visibly. A form + // can safely return protocol cancellation so direct clients + // can continue without an interactive operator. + if request.method == "session/request_permission" { + fatal.cancel(); + } + let response = Ok(operator::response_for(&interaction, OperatorAnswer::Cancel)); + let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; + let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if output.admit(frame, &limits) { + return responder.respond_with_result(response); } - } else { - Err(agent_client_protocol::Error::method_not_found()) - }; + return Err(agent_client_protocol::Error::internal_error()); + } + let response = Err(agent_client_protocol::Error::method_not_found()); let frame = serde_json::to_string(&RawJsonRpcMessage::response( responder.id().clone(), response.clone(), @@ -1310,6 +1530,10 @@ async fn run_connection( phase_error = Some(error); return Err(agent_client_protocol::Error::internal_error()); } + if driver.is_none() { + *operator_router.lock().unwrap_or_else(|e| e.into_inner()) = + Some((None, one_turn_epoch.clone())); + } if driver.is_some() && let Err(error) = service_sender.end_turn(limits.setup_timeout).await { phase_error = Some(error); @@ -1640,6 +1864,97 @@ mod tests { )); } + #[test] + fn concurrent_operator_reservations_keep_sdk_enqueue_order() { + let output = Arc::new(Mutex::new(CallbackOutput::default())); + let sent = Arc::new(Mutex::new(Vec::new())); + let limits = ClientLimits::default(); + let (entered, first_enqueuing) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel(); + let first = { + let output = Arc::clone(&output); + let sent = Arc::clone(&sent); + let limits = limits.clone(); + std::thread::spawn(move || { + reserve_and_enqueue_operator_response( + &output, + "first".into(), + &limits, + &mut None, + || { + entered.send(()).expect("first entered SDK enqueue"); + released.recv().expect("release first enqueue"); + sent.lock().expect("sent frames").push("first"); + }, + ) + }) + }; + first_enqueuing + .recv() + .expect("first has reserved its frame"); + assert!( + output.try_lock().is_err(), + "reservation must stay locked through SDK enqueue" + ); + let second = { + let output = Arc::clone(&output); + let sent = Arc::clone(&sent); + let limits = limits.clone(); + std::thread::spawn(move || { + reserve_and_enqueue_operator_response( + &output, + "second".into(), + &limits, + &mut None, + || { + sent.lock().expect("sent frames").push("second"); + }, + ) + }) + }; + release.send(()).expect("release first enqueue"); + first + .join() + .expect("first task") + .expect("first reservation"); + second + .join() + .expect("second task") + .expect("second reservation"); + assert_eq!(*sent.lock().expect("sent frames"), ["first", "second"]); + assert_eq!( + output + .lock() + .expect("reservations") + .frames + .iter() + .map(|(frame, _, _)| frame.as_str()) + .collect::>(), + ["first", "second"] + ); + } + + #[test] + fn operator_acknowledgement_tracks_flush_or_sink_failure() { + let mut output = CallbackOutput::default(); + let limits = ClientLimits::default(); + let (success, mut flushed) = oneshot::channel(); + let mut success = Some(success); + assert!(output.admit_with_ack("first".into(), &limits, false, &mut success)); + assert!(success.is_none()); + assert!( + flushed.try_recv().is_err(), + "SDK enqueue is not transport delivery" + ); + output.flushed(); + assert!(flushed.try_recv().expect("flush acknowledgement")); + + let (failure, mut failed) = oneshot::channel(); + assert!(output.admit_with_ack("second".into(), &limits, false, &mut Some(failure))); + output.fail_all(); + assert!(!failed.try_recv().expect("write failure acknowledgement")); + } + #[test] fn usage_counters_survive_frame_redaction_without_exempting_credentials() { let capture = Capture { diff --git a/crates/opensymphony-acp/src/operator.rs b/crates/opensymphony-acp/src/operator.rs new file mode 100644 index 000000000..f6065a3fa --- /dev/null +++ b/crates/opensymphony-acp/src/operator.rs @@ -0,0 +1,643 @@ +//! Bounded, typed operator callbacks. No arbitrary ACP method passthrough. +use super::*; +use crate::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteraction, OperatorInteractionKind, OperatorOption, OperatorQuestion, +}; +use crate::opensymphony_workflow::AcpPermissionPolicy; +use agent_client_protocol::schema::v1::{ + CreateElicitationRequest, ElicitationMode, ElicitationPropertySchema, ElicitationScope, + MultiSelectItems, RequestPermissionRequest, +}; +use chrono::{Duration as ChronoDuration, Utc}; +use std::{ + collections::{HashMap, HashSet}, + sync::atomic::{AtomicU8, Ordering}, +}; + +/// A failed operator receipt must fence a response still waiting in the worker queue. +/// Claiming delivery and cancelling it are mutually exclusive atomic transitions. +#[derive(Clone, Default)] +pub struct AcpOperatorDeliveryFence(Arc); + +impl AcpOperatorDeliveryFence { + pub fn cancel(&self) -> bool { + self.0 + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) == 1 + } + + pub fn claim(&self) -> bool { + self.0 + .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } +} + +pub struct AcpOperatorRequest { + pub interaction: OperatorInteraction, + pub reply: oneshot::Sender, +} + +pub struct AcpOperatorReply { + pub answer: OperatorAnswer, + pub acknowledgement: oneshot::Sender, + pub delivery: AcpOperatorDeliveryFence, +} + +pub enum AcpOperatorEvent { + Opened(Box), + Closed(String), +} + +pub(super) type OperatorRouter = + Arc>, CancellationToken)>>>; + +fn bounded(value: &str, max: usize) -> bool { + !value.trim().is_empty() && value.len() <= max && !value.chars().any(char::is_control) +} + +fn no_secret_prompt(value: &str) -> bool { + // ponytail: keyword filter rejects some benign prompts; use a reviewed + // classification policy if agents need sensitive multiple-choice input. + let lower = value.to_ascii_lowercase(); + ![ + "password", + "secret", + "token", + "credential", + "api key", + "private key", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +pub(super) fn same_binding_ids(safe: &OperatorInteraction, original: &OperatorInteraction) -> bool { + let ids = |interaction: &OperatorInteraction| { + let options = interaction + .options + .iter() + .map(|option| option.id.clone()) + .collect::>(); + let questions = interaction + .questions + .iter() + .map(|question| { + ( + question.id.clone(), + question + .options + .iter() + .map(|option| option.id.clone()) + .collect::>(), + ) + }) + .collect::>(); + (options, questions) + }; + ids(safe) == ids(original) +} + +pub(super) fn parse_interaction( + method: &str, + params: &Value, + rpc_id: Value, + session_id: &str, + timeout: Duration, +) -> Result { + let invalid = agent_client_protocol::Error::invalid_params; + let (kind, title, options, questions, plan) = match method { + "session/request_permission" => { + let request: RequestPermissionRequest = + serde_json::from_value(params.clone()).map_err(|_| invalid())?; + if request.session_id.0.as_ref() != session_id + || request.options.is_empty() + || request.options.len() > 32 + { + return Err(invalid()); + } + let title = request + .tool_call + .fields + .title + .unwrap_or_else(|| "ACP tool permission".into()); + let options = request + .options + .into_iter() + .map(|option| OperatorOption { + id: option.option_id.0.to_string(), + label: option.name, + kind: serde_json::to_value(option.kind) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_default(), + }) + .collect::>(); + ( + OperatorInteractionKind::Permission, + title, + options, + Vec::new(), + None, + ) + } + "elicitation/create" => { + let request: CreateElicitationRequest = + serde_json::from_value(params.clone()).map_err(|_| invalid())?; + let ElicitationMode::Form(form) = request.mode else { + return Err(invalid()); // URL elicitation needs an out-of-band consent flow. + }; + let ElicitationScope::Session(scope) = form.scope else { + return Err(invalid()); + }; + if scope.session_id.0.as_ref() != session_id + || form.requested_schema.properties.is_empty() + || form.requested_schema.properties.len() > 8 + { + return Err(invalid()); + } + let required = form.requested_schema.required.ok_or_else(invalid)?; + if required.len() != form.requested_schema.properties.len() + || required.iter().collect::>().len() != required.len() + || required + .iter() + .any(|id| !form.requested_schema.properties.contains_key(id)) + { + return Err(invalid()); + } + let mut questions = Vec::new(); + for (id, property) in form.requested_schema.properties { + let (prompt, choices, allow_multiple) = match property { + ElicitationPropertySchema::String(field) => { + if field.min_length.is_some() + || field.max_length.is_some() + || field.pattern.is_some() + || field.format.is_some() + { + return Err(invalid()); + } + let choices = match (field.enum_values, field.one_of) { + (Some(values), None) => values + .into_iter() + .map(|value| (value.clone(), value)) + .collect(), + (None, Some(values)) => values + .into_iter() + .map(|option| (option.value, option.title)) + .collect(), + _ => return Err(invalid()), + }; + (field.title.unwrap_or_else(|| id.clone()), choices, false) + } + ElicitationPropertySchema::Array(field) => { + if field.min_items.is_some() || field.max_items.is_some() { + return Err(invalid()); + } + let choices = match field.items { + MultiSelectItems::String(items) => items + .values + .into_iter() + .map(|value| (value.clone(), value)) + .collect(), + MultiSelectItems::Titled(items) => items + .options + .into_iter() + .map(|option| (option.value, option.title)) + .collect(), + _ => return Err(invalid()), + }; + (field.title.unwrap_or_else(|| id.clone()), choices, true) + } + _ => return Err(invalid()), + }; + let choices: Vec<(String, String)> = choices; + if !bounded(&id, 128) + || !bounded(&prompt, 2048) + || !no_secret_prompt(&id) + || !no_secret_prompt(&prompt) + || choices.is_empty() + || choices.len() > 32 + || choices + .iter() + .map(|(id, _)| id) + .collect::>() + .len() + != choices.len() + || choices.iter().any(|(id, label)| { + !bounded(id, 128) + || !bounded(label, 1024) + || !no_secret_prompt(id) + || !no_secret_prompt(label) + }) + { + return Err(invalid()); + } + questions.push(OperatorQuestion { + id, + prompt, + options: choices + .into_iter() + .map(|(id, label)| OperatorOption { + id, + label, + kind: "choice".into(), + }) + .collect(), + allow_multiple, + }); + } + ( + OperatorInteractionKind::Question, + request.message, + Vec::new(), + questions, + None, + ) + } + _ => return Err(agent_client_protocol::Error::method_not_found()), + }; + if !bounded(&title, 2048) + || !no_secret_prompt(&title) + || !rpc_id.is_number() && !rpc_id.is_string() + { + return Err(invalid()); + } + let mut option_ids = HashSet::new(); + if options.iter().any(|option| { + !bounded(&option.id, 128) + || !bounded(&option.label, 1024) + || !no_secret_prompt(&option.label) + || !option_ids.insert(option.id.as_str()) + }) { + return Err(invalid()); + } + let requested_at = Utc::now(); + let expires_at = requested_at + ChronoDuration::from_std(timeout).map_err(|_| invalid())?; + Ok(OperatorInteraction { + request_id: uuid::Uuid::new_v4().to_string(), + run_id: String::new(), + issue_id: String::new(), + issue_identifier: String::new(), + session_id: session_id.into(), + generation: 0, + // The SDK responder retains the exact peer ID privately. Public + // clients echo only this generated binding token, never peer input. + rpc_id: uuid::Uuid::new_v4().to_string(), + kind, + title, + options, + questions, + plan, + requested_at, + expires_at, + }) +} + +pub(super) fn automatic_answer( + policy: AcpPermissionPolicy, + interaction: &OperatorInteraction, +) -> Option { + if interaction.kind != OperatorInteractionKind::Permission { + return None; + } + let kind = match policy { + AcpPermissionPolicy::Operator => return None, + AcpPermissionPolicy::Deny => "reject_once", + AcpPermissionPolicy::AllowOnce => "allow_once", + }; + Some( + interaction + .options + .iter() + .find(|option| option.kind == kind) + .map(|option| OperatorAnswer::Permission { + option_id: option.id.clone(), + }) + .unwrap_or(OperatorAnswer::Cancel), + ) +} + +pub(super) fn response_for(interaction: &OperatorInteraction, answer: OperatorAnswer) -> Value { + match (interaction.kind, answer) { + (OperatorInteractionKind::Permission, OperatorAnswer::Permission { option_id }) + if interaction + .options + .iter() + .any(|option| option.id == option_id) => + { + json!({"outcome": {"outcome": "selected", "optionId": option_id}}) + } + (OperatorInteractionKind::Permission, _) => json!({"outcome": {"outcome": "cancelled"}}), + (OperatorInteractionKind::Question, OperatorAnswer::Question { answers }) => { + let valid = answers.len() == interaction.questions.len() + && interaction.questions.iter().all(|question| { + answers + .iter() + .filter(|answer| answer.question_id == question.id) + .count() + == 1 + && answers + .iter() + .find(|answer| answer.question_id == question.id) + .is_some_and(|answer| { + !answer.selected_option_ids.is_empty() + && (question.allow_multiple + || answer.selected_option_ids.len() == 1) + && answer + .selected_option_ids + .iter() + .collect::>() + .len() + == answer.selected_option_ids.len() + && answer.selected_option_ids.iter().all(|id| { + question.options.iter().any(|option| &option.id == id) + }) + }) + }); + if !valid { + return json!({"action": "cancel"}); + } + let content = answers + .into_iter() + .map(|answer| { + let multiple = interaction.questions.iter().any(|question| { + question.id == answer.question_id && question.allow_multiple + }); + let value = if multiple { + json!(answer.selected_option_ids) + } else { + json!(answer.selected_option_ids[0]) + }; + (answer.question_id, value) + }) + .collect::>(); + json!({"action": "accept", "content": content}) + } + (OperatorInteractionKind::Question, OperatorAnswer::Decline) => { + json!({"action": "decline"}) + } + (OperatorInteractionKind::Question, _) => json!({"action": "cancel"}), + (OperatorInteractionKind::PlanApproval, OperatorAnswer::Plan { accepted: true }) => { + json!({"outcome": {"outcome": "accepted"}}) + } + ( + OperatorInteractionKind::PlanApproval, + OperatorAnswer::Plan { accepted: false } | OperatorAnswer::Decline, + ) => json!({"outcome": {"outcome": "rejected"}}), + (OperatorInteractionKind::PlanApproval, _) => json!({"outcome": {"outcome": "cancelled"}}), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opensymphony_gateway_schema::approval::OperatorQuestionAnswer; + + #[test] + fn permission_uses_only_offered_option_and_keeps_peer_rpc_id_private() { + let request = json!({"sessionId":"session-1","toolCall":{"toolCallId":"tool-1","title":"Run tests"}, + "options":[{"optionId":"opaque-allow","name":"Allow once","kind":"allow_once"}, + {"optionId":"opaque-deny","name":"Deny","kind":"reject_once"}]}); + let interaction = parse_interaction( + "session/request_permission", + &request, + json!(0), + "session-1", + Duration::from_secs(30), + ) + .expect("permission"); + assert!(uuid::Uuid::parse_str(&interaction.rpc_id).is_ok()); + assert_ne!(interaction.rpc_id, "0"); + let large_id = parse_interaction( + "session/request_permission", + &request, + json!(9_007_199_254_740_993_u64), + "session-1", + Duration::from_secs(30), + ) + .expect("large numeric RPC ID"); + assert!(uuid::Uuid::parse_str(&large_id.rpc_id).is_ok()); + assert_ne!(large_id.rpc_id, interaction.rpc_id); + assert_eq!( + serde_json::to_value(&large_id).expect("public binding")["rpc_id"], + large_id.rpc_id + ); + let sensitive_id = parse_interaction( + "session/request_permission", + &request, + json!("credential-as-rpc-id"), + "session-1", + Duration::from_secs(30), + ) + .expect("sensitive string ID"); + assert!( + !serde_json::to_string(&sensitive_id) + .expect("public interaction") + .contains("credential-as-rpc-id") + ); + assert_eq!( + response_for( + &interaction, + OperatorAnswer::Permission { + option_id: "opaque-allow".into() + } + ), + json!({"outcome":{"outcome":"selected","optionId":"opaque-allow"}}) + ); + assert_eq!( + response_for( + &interaction, + OperatorAnswer::Permission { + option_id: "invented".into() + } + ), + json!({"outcome":{"outcome":"cancelled"}}) + ); + assert_eq!( + automatic_answer(AcpPermissionPolicy::Deny, &interaction), + Some(OperatorAnswer::Permission { + option_id: "opaque-deny".into() + }) + ); + assert_eq!( + automatic_answer(AcpPermissionPolicy::AllowOnce, &interaction), + Some(OperatorAnswer::Permission { + option_id: "opaque-allow".into() + }) + ); + let mut no_matching_option = interaction.clone(); + no_matching_option + .options + .retain(|option| option.kind != "allow_once"); + assert_eq!( + automatic_answer(AcpPermissionPolicy::AllowOnce, &no_matching_option), + Some(OperatorAnswer::Cancel) + ); + assert!( + parse_interaction( + "session/request_permission", + &request, + json!(0), + "wrong-session", + Duration::from_secs(30) + ) + .is_err() + ); + } + + #[test] + fn structured_questions_accept_only_offered_complete_answers() { + let request = json!({"sessionId":"s","mode":"form","message":"Choose deployment","requestedSchema":{ + "type":"object","required":["region","checks"],"properties":{ + "region":{"type":"string","title":"Region?","oneOf":[{"const":"east","title":"East"},{"const":"west","title":"West"}]}, + "checks":{"type":"array","title":"Checks?","items":{"type":"string","enum":["lint","test"]}} + }}}); + let interaction = parse_interaction( + "elicitation/create", + &request, + json!("rpc-2"), + "s", + Duration::from_secs(30), + ) + .expect("question"); + let mut reordered = interaction.clone(); + reordered.questions.reverse(); + for question in &mut reordered.questions { + question.options.reverse(); + } + assert!(same_binding_ids(&interaction, &reordered)); + reordered.questions[0].options[0].id = "substituted-opaque-id".into(); + assert!(!same_binding_ids(&interaction, &reordered)); + let answer = OperatorAnswer::Question { + answers: vec![ + OperatorQuestionAnswer { + question_id: "region".into(), + selected_option_ids: vec!["west".into()], + }, + OperatorQuestionAnswer { + question_id: "checks".into(), + selected_option_ids: vec!["lint".into(), "test".into()], + }, + ], + }; + assert_eq!( + response_for(&interaction, answer), + json!({"action":"accept","content":{"region":"west","checks":["lint","test"]}}) + ); + let bad = OperatorAnswer::Question { + answers: vec![ + OperatorQuestionAnswer { + question_id: "region".into(), + selected_option_ids: vec!["invented".into()], + }, + OperatorQuestionAnswer { + question_id: "checks".into(), + selected_option_ids: vec!["lint".into()], + }, + ], + }; + assert_eq!(response_for(&interaction, bad)["action"], "cancel"); + assert_eq!( + response_for(&interaction, OperatorAnswer::Decline)["action"], + "decline" + ); + } + + #[test] + fn plan_response_and_sensitive_question_boundary() { + let mut interaction = parse_interaction( + "session/request_permission", + &json!({"sessionId":"s","toolCall":{"toolCallId":"t","title":"Ship"}, + "options":[{"optionId":"allow","name":"Allow once","kind":"allow_once"}]}), + json!(7), + "s", + Duration::from_secs(30), + ) + .expect("typed interaction"); + interaction.kind = OperatorInteractionKind::PlanApproval; + interaction.options.clear(); + interaction.plan = Some("Run smoke tests".into()); + assert_eq!( + response_for(&interaction, OperatorAnswer::Plan { accepted: true })["outcome"]["outcome"], + "accepted" + ); + assert_eq!( + response_for(&interaction, OperatorAnswer::Cancel)["outcome"]["outcome"], + "cancelled" + ); + assert!(parse_interaction("elicitation/create", &json!({"sessionId":"s","mode":"form","message":"Enter API key","requestedSchema":{"type":"object","required":["key"],"properties":{"key":{"type":"string","enum":["a"]}}}}), json!(8), "s", Duration::from_secs(30)).is_err()); + assert!(parse_interaction("elicitation/create", &json!({"sessionId":"s","mode":"url","url":"https://example.com","elicitationId":"e"}), json!(8), "s", Duration::from_secs(30)).is_err()); + assert!( + parse_interaction( + "cursor/create_plan", + &json!({"sessionId":"s","plan":"Ship"}), + json!(9), + "s", + Duration::from_secs(30) + ) + .is_err() + ); + assert!( + parse_interaction( + "cursor/ask_question", + &json!({"sessionId":"s","questions":[]}), + json!(10), + "s", + Duration::from_secs(30) + ) + .is_err() + ); + } + + #[test] + fn form_schema_rejects_free_text_optional_and_unsupported_constraints() { + let base = json!({"sessionId":"s","mode":"form","message":"Choose a region", + "requestedSchema":{"type":"object","required":["region"],"properties":{ + "region":{"type":"string","enum":["east","west"]}}}}); + for property in [ + json!({"type":"string"}), + json!({"type":"number"}), + json!({"type":"string","enum":["east","west"],"pattern":"^east$"}), + json!({"type":"string","enum":["east","east"]}), + ] { + let mut request = base.clone(); + request["requestedSchema"]["properties"]["region"] = property; + assert!( + parse_interaction( + "elicitation/create", + &request, + json!(0), + "s", + Duration::from_secs(30) + ) + .is_err() + ); + } + let mut optional = base.clone(); + optional["requestedSchema"]["required"] = json!([]); + assert!( + parse_interaction( + "elicitation/create", + &optional, + json!(0), + "s", + Duration::from_secs(30) + ) + .is_err() + ); + assert!( + parse_interaction( + "elicitation/create", + &base, + json!(0), + "other", + Duration::from_secs(30) + ) + .is_err() + ); + } +} diff --git a/crates/opensymphony-acp/src/projection.rs b/crates/opensymphony-acp/src/projection.rs index bb3b1bf11..08678a782 100644 --- a/crates/opensymphony-acp/src/projection.rs +++ b/crates/opensymphony-acp/src/projection.rs @@ -279,18 +279,6 @@ impl RuntimeProjection { fn project(&mut self, generation: u64, frame: &SourceFrame) -> Option { let method = frame.payload.get("method")?.as_str()?; - if method == "session/request_permission" { - return Some(RuntimeUpdate { - sequence: frame.sequence, - generation, - observed_at: frame.observed_at, - kind: "waiting_for_input".into(), - summary: Some( - "ACP agent requested permission; operator responses are unavailable".into(), - ), - payload: json!({"reason":"permission_request", "operator_responses":false}), - }); - } if method != "session/update" { return None; } @@ -451,7 +439,7 @@ pub fn run_capability( history_replay: caps["loadSession"].as_bool() == Some(true), model_selection: state.model_selection, cancellation: true, - operator_responses: false, + operator_responses: true, } } @@ -760,6 +748,24 @@ mod tests { event } + #[test] + fn raw_operator_callbacks_do_not_claim_an_unrouted_wait() { + let mut projection = RuntimeProjection::default(); + for (sequence, method) in [(1, "session/request_permission"), (2, "elicitation/create")] { + let update = projection.apply( + &callback_event( + sequence, + "incoming", + json!({ + "id":sequence,"method":method,"params":{"sessionId":"s","secret":"private"} + }), + ), + "run", + ); + assert!(update.is_none(), "only accepted routed requests may wait"); + } + } + #[test] fn terminal_callback_responses_project_completed_command_once() { let mut projection = RuntimeProjection::default(); diff --git a/crates/opensymphony-acp/src/services.rs b/crates/opensymphony-acp/src/services.rs index 798f7d1c6..d43d1bafa 100644 --- a/crates/opensymphony-acp/src/services.rs +++ b/crates/opensymphony-acp/src/services.rs @@ -50,6 +50,10 @@ impl HostServices { pub(super) fn capabilities(&self) -> ClientCapabilities { ClientCapabilities::default() + .elicitation( + agent_client_protocol::schema::v1::ElicitationCapabilities::new() + .form(agent_client_protocol::schema::v1::ElicitationFormCapabilities::new()), + ) .fs(FileSystemCapabilities::default() .read_text_file(self.read_files) .write_text_file(self.write_files)) @@ -76,6 +80,35 @@ pub(super) struct CallbackSender { bytes: Arc, } impl CallbackSender { + fn reserve( + &self, + method: &str, + params: &Value, + id_bytes: usize, + ) -> Result<(OwnedSemaphorePermit, OwnedSemaphorePermit), Error> { + let permit = self + .permits + .clone() + .try_acquire_owned() + .map_err(|_| Error::internal_error())?; + let size = serde_json::to_vec(params).map_err(Error::from)?.len() + id_bytes + method.len(); + let bytes = self + .bytes + .clone() + .try_acquire_many_owned(u32::try_from(size).map_err(|_| Error::internal_error())?) + .map_err(|_| Error::internal_error())?; + Ok((permit, bytes)) + } + + pub fn reserve_operator( + &self, + method: &str, + params: &Value, + rpc_id: &str, + ) -> Result<(OwnedSemaphorePermit, OwnedSemaphorePermit), Error> { + self.reserve(method, params, rpc_id.len()) + } + /// Quiesce callbacks without installing another active epoch. pub async fn end_turn(&self, timeout: Duration) -> Result<(), ClientError> { let (tx, rx) = oneshot::channel(); @@ -114,21 +147,10 @@ impl CallbackSender { params: Value, responder: Responder, ) -> Result<(), Error> { - let permit = self - .permits - .clone() - .try_acquire_owned() - .map_err(|_| Error::internal_error())?; - let size = serde_json::to_vec(¶ms).map_err(Error::from)?.len() - + serde_json::to_vec(responder.id()) - .map_err(Error::from)? - .len() - + method.len(); - let bytes = self - .bytes - .clone() - .try_acquire_many_owned(u32::try_from(size).map_err(|_| Error::internal_error())?) - .map_err(|_| Error::internal_error())?; + let id_bytes = serde_json::to_vec(responder.id()) + .map_err(Error::from)? + .len(); + let (permit, bytes) = self.reserve(&method, ¶ms, id_bytes)?; self.tx .try_send(ServiceCommand::Callback(Box::new(Callback { method, @@ -959,6 +981,33 @@ async fn run_terminal( #[cfg(test)] mod tests { use super::*; + + #[test] + fn operator_callbacks_share_the_pending_callback_budget() { + let (tx, _rx) = mpsc::channel(1); + let sender = CallbackSender { + tx, + permits: Arc::new(Semaphore::new(1)), + bytes: Arc::new(Semaphore::new(1024)), + }; + let first = sender + .reserve_operator("session/request_permission", &json!({}), "0") + .expect("first callback reservation"); + assert!( + sender + .reserve_operator("session/request_permission", &json!({}), "1") + .is_err(), + "a pending operator callback consumes the shared callback permit" + ); + drop(first); + assert!( + sender + .reserve_operator("session/request_permission", &json!({}), "1") + .is_ok(), + "completion releases the callback permit" + ); + } + #[cfg(unix)] #[tokio::test] async fn callbacks_keep_original_workspace_after_root_path_replacement() { diff --git a/crates/opensymphony-cli/src/lib.rs b/crates/opensymphony-cli/src/lib.rs index bff7de43b..6453ee1eb 100644 --- a/crates/opensymphony-cli/src/lib.rs +++ b/crates/opensymphony-cli/src/lib.rs @@ -2056,6 +2056,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { }; let issues = vec![ IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), @@ -2109,6 +2110,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { operator: None, }, IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "OSYM-401".to_owned(), title: "Control-plane API and snapshot store".to_owned(), @@ -2158,6 +2160,7 @@ fn sample_snapshot(step: u64) -> DaemonSnapshot { operator: None, }, IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "OSYM-402".to_owned(), title: "FrankenTUI operator client".to_owned(), diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 5e7395870..58dd703e8 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -66,7 +66,7 @@ use tokio::{ fs, io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::{ChildStderr, ChildStdin, Command}, - sync::{Mutex as AsyncMutex, mpsc, oneshot}, + sync::{Mutex as AsyncMutex, Notify, mpsc, oneshot}, task::JoinHandle, time::{timeout, timeout_at}, }; @@ -421,6 +421,8 @@ pub(super) enum CliWorkerError { Join(#[from] tokio::task::JoinError), #[error("worker interrupt failed: {0}")] InterruptFailed(String), + #[error("ACP operator response is retryable: {0}")] + OperatorResponseRetryable(String), } #[derive(Debug)] @@ -505,6 +507,7 @@ pub(super) struct RuntimeWorkerBackend { launch_timeout: Duration, updates_tx: mpsc::UnboundedSender, updates_rx: mpsc::UnboundedReceiver, + operator_update_notify: Arc, tasks: HashMap, worker_issue_ids: HashMap, } @@ -5636,6 +5639,7 @@ impl RuntimeWorkerBackend { launch_timeout: DEFAULT_WORKER_LAUNCH_TIMEOUT, updates_tx, updates_rx, + operator_update_notify: Arc::new(Notify::new()), tasks: HashMap::new(), worker_issue_ids: HashMap::new(), } @@ -5646,6 +5650,10 @@ impl RuntimeWorkerBackend { self } + pub(super) fn operator_update_notify(&self) -> Arc { + self.operator_update_notify.clone() + } + pub(super) fn with_integration_instructions( mut self, instructions: Option, @@ -5734,6 +5742,7 @@ impl RuntimeWorkerBackend { let openhands_conversation_store = self.openhands_conversation_store.clone(); let workflow = self.workflow.clone(); let updates_tx = self.updates_tx.clone(); + let operator_update_notify = self.operator_update_notify.clone(); let worker_id = request.run.worker_id.clone(); let issue_identifier = issue.identifier.to_string(); self.worker_issue_ids @@ -7012,6 +7021,7 @@ impl RuntimeWorkerBackend { &acp_host, &acp_active, &updates_tx, + &operator_update_notify, &mut launch_tx, worker_environment, checkout_credential_envs, @@ -7032,6 +7042,7 @@ impl RuntimeWorkerBackend { worker_id: finished_worker_id.clone(), outcome, }); + operator_update_notify.notify_one(); return; } if !matches!( @@ -10324,6 +10335,78 @@ impl WorkerBackend for RuntimeWorkerBackend { Ok(updates) } + async fn respond_operator_request( + &mut self, + worker_id: &crate::opensymphony_domain::WorkerId, + request_id: &str, + answer: crate::opensymphony_gateway_schema::approval::OperatorAnswer, + ) -> Result { + self.begin_operator_response(worker_id, request_id, answer) + .await? + .wait() + .await + .map_err(CliWorkerError::OperatorResponseRetryable) + } + + async fn begin_operator_response( + &mut self, + worker_id: &crate::opensymphony_domain::WorkerId, + request_id: &str, + answer: crate::opensymphony_gateway_schema::approval::OperatorAnswer, + ) -> Result { + let sender = self + .acp_active + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(worker_id.as_str()) + .filter(|session| session.run_id == format!("run-{worker_id}")) + .map(|session| session.operator_responses.clone()); + let Some(sender) = sender else { + return Ok( + crate::opensymphony_orchestrator::OperatorResponseDelivery::new(async { + Ok(false) + }), + ); + }; + let delivery = crate::opensymphony_acp::AcpOperatorDeliveryFence::default(); + let (acknowledgement, received) = oneshot::channel(); + match sender.try_send(acp::OperatorResponseCommand { + request_id: request_id.into(), + answer, + acknowledgement, + delivery: delivery.clone(), + }) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + return Err(CliWorkerError::OperatorResponseRetryable( + "worker response queue is full".into(), + )); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + return Ok( + crate::opensymphony_orchestrator::OperatorResponseDelivery::new(async { + Ok(false) + }), + ); + } + } + Ok( + crate::opensymphony_orchestrator::OperatorResponseDelivery::new(async move { + tokio::pin!(received); + match timeout(Duration::from_secs(5), &mut received).await { + Ok(result) => Ok(result.unwrap_or(false)), + Err(_) if delivery.cancel() => Err( + "worker did not consume the queued answer before its acknowledgement deadline" + .into(), + ), + // Delivery already crossed the atomic fence. Wait for its real + // acknowledgement instead of publishing a false failure receipt. + Err(_) => Ok(received.await.unwrap_or(false)), + } + }), + ) + } + async fn abort_worker( &mut self, worker_id: &crate::opensymphony_domain::WorkerId, @@ -17394,6 +17477,8 @@ exit 64 "crash", "setup_retry", "permission", + "operator_roundtrip", + "operator_early_finish", "corrupt", "configured", "slow_model_hang", @@ -17483,6 +17568,357 @@ exit 64 panic!("ACP worker did not finish") } + struct OperatorTracker(TrackerIssue); + + impl TrackerBackend for OperatorTracker { + type Error = io::Error; + + async fn candidate_issues(&mut self) -> Result, Self::Error> { + Ok(vec![self.0.clone()]) + } + + async fn terminal_issues(&mut self) -> Result, Self::Error> { + Ok(Vec::new()) + } + + async fn issue_states_by_ids( + &mut self, + ids: &[String], + ) -> Result, Self::Error> + { + Ok(ids + .iter() + .filter(|id| *id == &self.0.id) + .map(|_| crate::opensymphony_domain::TrackerIssueStateSnapshot { + id: self.0.id.clone(), + identifier: self.0.identifier.clone(), + state: crate::opensymphony_domain::TrackerIssueState { + id: "state-active".into(), + name: self.0.state.clone(), + tracker_type: "started".into(), + kind: TrackerIssueStateKind::Started, + }, + project_id: None, + project_slug: None, + project_identity_known: false, + labels: Vec::new(), + is_parent: false, + updated_at: chrono::Utc::now(), + }) + .collect()) + } + } + + #[test] + fn acp_production_scheduler_gateway_to_native_rpc_round_trip() { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("integration runtime") + .block_on(acp_production_round_trip_inner()) + }) + .expect("integration thread") + .join() + .expect("integration result"); + } + + async fn acp_production_round_trip_inner() { + use crate::opensymphony_control::{AgentServerStatus, MemoryServerStatus, SnapshotStore}; + use crate::opensymphony_gateway::{GatewayServer, OperatorCommand}; + use crate::opensymphony_gateway_schema::{ + action::{ActionDispatch, ActionKind, ActionReceipt, ActionStatus, ActionTarget}, + approval::{OperatorAnswer, OperatorInteraction, OperatorInteractionKind}, + envelope::EntityKind, + version::SchemaVersion, + }; + use crate::opensymphony_orchestrator::{Scheduler, SchedulerConfig}; + + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + Arc::make_mut(&mut backend.workflow) + .config + .polling + .interval_ms = 300_000; + let workflow = backend.workflow.clone(); + let workspace = RuntimeWorkspaceBackend::new(manager.clone(), &workflow); + let mut config = SchedulerConfig::from_workflow(&workflow).expect("config"); + config.routing.harness_profile = Some("operator_roundtrip".into()); + config.routing.model = None; + config.routing.model_from_env = false; + config.stall_timeout_ms = None; + let mut tracker_issue = sample_tracker_issue(&sample_issue()); + tracker_issue.project_slug = Some("sample-project".into()); + let operator_update_notify = backend.operator_update_notify(); + let mut scheduler = + Scheduler::new(OperatorTracker(tracker_issue), workspace, backend, config); + let mut observed_at = now_timestamp().as_u64(); + let agent_status = || AgentServerStatus { + reachable: true, + base_url: String::new(), + conversation_count: 0, + status_line: "ACP local stdio".into(), + }; + let terminal_states = HashSet::from(["done".to_owned()]); + let project = |snapshot: &crate::opensymphony_domain::OrchestratorSnapshot| { + super::super::snapshot::map_snapshot( + snapshot, + &manager.config().root, + &terminal_states, + agent_status(), + MemoryServerStatus::default(), + &VecDeque::new(), + ) + }; + let store = SnapshotStore::new(project(&scheduler.snapshot(TimestampMs::new(observed_at)))); + let (commands_tx, mut commands_rx) = mpsc::channel::(8); + let gateway = GatewayServer::new(store.clone()).with_operator_commands(commands_tx); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("gateway bind"); + let address = listener.local_addr().expect("gateway address"); + let server = tokio::spawn(async move { gateway.serve(listener).await.expect("gateway") }); + let client = reqwest::Client::new(); + + observed_at += 1000; + let dispatched = scheduler + .tick(TimestampMs::new(observed_at)) + .await + .expect("initial tracker dispatch"); + store.publish(project(&dispatched)).await; + + for (kind, action_kind, answer_fields) in [ + ( + OperatorInteractionKind::Permission, + ActionKind::ApprovalDecision, + serde_json::json!({"decision":"approved","option_id":"allow-opaque"}), + ), + ( + OperatorInteractionKind::Question, + ActionKind::InputResponse, + serde_json::json!({"outcome":"answered","answers":[{"question_id":"region","selected_option_ids":["west"]}]}), + ), + ] { + let interaction: OperatorInteraction = { + let mut found = None; + let mut last = String::new(); + for _ in 0..100 { + timeout(Duration::from_secs(5), operator_update_notify.notified()) + .await + .expect("native ACP callback wakes scheduler without tracker polling"); + observed_at += 1000; + let snapshot = scheduler + .drain_worker_updates(TimestampMs::new(observed_at)) + .await + .expect("operator update drain"); + last = format!( + "health={:?} issues={:?} pending={:?}", + snapshot.daemon.health, + snapshot + .issues + .iter() + .map(|issue| ( + &issue.issue.identifier, + &issue.runtime.state, + &issue.last_worker_outcome + )) + .collect::>(), + snapshot + .operator_interactions + .iter() + .map(|item| item.kind) + .collect::>() + ); + store.publish(project(&snapshot)).await; + found = snapshot + .operator_interactions + .into_iter() + .find(|item| item.kind == kind); + if found.is_some() { + assert!( + snapshot.issues.iter().any(|issue| { + issue.conversation.as_ref().is_some_and(|conversation| { + conversation + .recent_activity + .iter() + .any(|event| event.kind == "acp.waiting_for_input") + }) + }), + "accepted routed callback records waiting activity" + ); + break; + } + } + found.unwrap_or_else(|| panic!("native peer request reached scheduler: {last}")) + }; + let route = if kind == OperatorInteractionKind::Question { + "inputs" + } else { + "approvals" + }; + let page: serde_json::Value = client + .get(format!("http://{address}/api/v1/runs/COE-284/{route}")) + .send() + .await + .expect("gateway read") + .json() + .await + .expect("page"); + assert!( + page[route] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + + let mut stale = interaction.clone(); + stale.generation += 1; + assert!( + scheduler + .respond_operator_request(&stale, OperatorAnswer::Cancel) + .await + .is_err() + ); + let mut payload = serde_json::json!({"request_id":interaction.request_id,"run_id":interaction.run_id, + "issue_id":interaction.issue_id,"session_id":interaction.session_id, + "generation":interaction.generation,"rpc_id":interaction.rpc_id}); + payload + .as_object_mut() + .expect("object") + .extend(answer_fields.as_object().expect("answer").clone()); + let action = ActionDispatch { + schema_version: SchemaVersion::v1(), + correlation_id: format!("operator-integration-{}", interaction.request_id), + action_kind, + target_entity: ActionTarget { + entity_kind: EntityKind::Run, + entity_id: "COE-284".into(), + }, + payload: Some(payload), + idempotency_key: None, + }; + let response = tokio::spawn({ + let client = client.clone(); + let url = format!("http://{address}/api/v1/actions/dispatch"); + async move { client.post(url).json(&action).send().await } + }); + let command = timeout(Duration::from_secs(5), commands_rx.recv()) + .await + .expect("gateway command timeout") + .expect("command"); + assert_eq!(command.interaction, interaction); + let delivered = scheduler + .respond_operator_request(&command.interaction, command.answer) + .await; + command.reply.send(delivered).expect("gateway reply"); + let receipt: ActionReceipt = response + .await + .expect("response task") + .expect("http response") + .json() + .await + .expect("receipt"); + assert_eq!(receipt.status, ActionStatus::Accepted, "{receipt:?}"); + assert!( + scheduler + .respond_operator_request(&interaction, OperatorAnswer::Cancel) + .await + .is_err(), + "accepted request cannot be replayed" + ); + } + let workspace = manager + .list_all_workspaces() + .await + .expect("workspace") + .remove(0) + .0; + let marker = workspace + .workspace_path() + .join("acp-operator-roundtrip.json"); + timeout(Duration::from_secs(5), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("native peer completed all three callbacks"); + assert_eq!( + serde_json::from_slice::(&fs::read(marker).expect("marker")) + .expect("json"), + serde_json::json!({"permission":"allow-opaque","question":"west"}) + ); + server.abort(); + } + + #[test] + fn acp_prompt_finish_wakes_scheduler_even_when_callback_close_loses_race() { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("integration runtime") + .block_on(acp_prompt_finish_wake_inner()) + }) + .expect("integration thread") + .join() + .expect("integration result"); + } + + async fn acp_prompt_finish_wake_inner() { + use crate::opensymphony_orchestrator::{Scheduler, SchedulerConfig}; + + let temp = TempDir::new().expect("temp"); + let (mut backend, manager) = acp_test_backend(temp.path()).await; + Arc::make_mut(&mut backend.workflow) + .config + .polling + .interval_ms = 300_000; + let workflow = backend.workflow.clone(); + let workspace = RuntimeWorkspaceBackend::new(manager, &workflow); + let mut config = SchedulerConfig::from_workflow(&workflow).expect("config"); + config.routing.harness_profile = Some("operator_early_finish".into()); + config.routing.model = None; + config.routing.model_from_env = false; + let notify = backend.operator_update_notify(); + let mut scheduler = Scheduler::new( + OperatorTracker(sample_tracker_issue(&sample_issue())), + workspace, + backend, + config, + ); + let mut observed_at = now_timestamp().as_u64(); + scheduler + .tick(TimestampMs::new(observed_at)) + .await + .expect("dispatch ACP worker"); + for _ in 0..10 { + timeout(Duration::from_secs(5), notify.notified()) + .await + .expect("ACP completion wakes scheduler without tracker tick"); + observed_at += 1_000; + let snapshot = scheduler + .drain_worker_updates(TimestampMs::new(observed_at)) + .await + .expect("drain ACP result"); + if snapshot + .issues + .iter() + .any(|issue| issue.last_worker_outcome.is_some()) + { + assert!( + snapshot.operator_interactions.is_empty(), + "finished prompt clears its outstanding callback" + ); + return; + } + } + panic!("ACP completion was not published after callback close race"); + } + #[tokio::test] async fn acp_worker_records_future_stop_reason_without_uncertain_submission() { let temp = TempDir::new().expect("temp"); @@ -19038,9 +19474,11 @@ exit 64 .await .expect("retained interrupt"); assert!(acknowledgement.accepted); + let retained_outcome = acp_test_finished(&mut backend).await; assert_eq!( - acp_test_finished(&mut backend).await.outcome, - WorkerOutcomeKind::Cancelled + retained_outcome.outcome, + WorkerOutcomeKind::Cancelled, + "{retained_outcome:?}" ); assert_eq!( fs::read_to_string(handle.workspace_path().join("acp-prompts.jsonl")) @@ -19301,7 +19739,7 @@ exit 64 } #[tokio::test] - async fn acp_worker_retries_pre_submission_failure_and_reports_permission_wait() { + async fn acp_worker_retries_pre_submission_failure_without_false_permission_wait() { let temp = TempDir::new().expect("temp"); let (mut backend, manager) = acp_test_backend(temp.path()).await; let root = manager.config().root.clone(); @@ -19351,7 +19789,10 @@ exit 64 } tokio::time::sleep(Duration::from_millis(10)).await; } - assert!(waiting, "permission waiting state reaches the scheduler"); + assert!( + !waiting, + "malformed permission must not claim a routed wait" + ); assert_eq!( finished.expect("finished").outcome, WorkerOutcomeKind::Cancelled diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs b/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs index cea6458b0..d6d3fb471 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs @@ -1,8 +1,8 @@ //! Production worker wiring. ACP wire types and protocol ownership stay in opensymphony_acp. use super::*; use crate::opensymphony_acp::{ - ClientLimits, HostServices, LaunchContext, RetentionPolicy, RuntimeProjection, SessionControl, - SessionEvent, SessionHandle, SessionHost, SessionLaunch, + AcpOperatorEvent, ClientLimits, HostServices, LaunchContext, RetentionPolicy, + RuntimeProjection, SessionControl, SessionEvent, SessionHandle, SessionHost, SessionLaunch, }; use crate::opensymphony_workspace::{AcpProcessState, AcpSessionIdentity, AcpSessionStatus}; use tokio_util::sync::CancellationToken; @@ -17,6 +17,13 @@ pub(super) struct ActiveSession { pub issue_id: IssueId, pub issue_identifier: IssueIdentifier, pub run_id: String, + pub operator_responses: mpsc::Sender, +} +pub(super) struct OperatorResponseCommand { + pub request_id: String, + pub answer: crate::opensymphony_gateway_schema::approval::OperatorAnswer, + pub acknowledgement: oneshot::Sender, + pub delivery: crate::opensymphony_acp::AcpOperatorDeliveryFence, } pub(super) type ActiveSessions = Arc>>; @@ -327,6 +334,7 @@ pub(super) async fn run_issue( host: &SessionHost, active: &ActiveSessions, updates: &mpsc::UnboundedSender, + operator_update_notify: &Notify, launch: &mut Option>, environment: BTreeMap, excluded_environment: BTreeSet, @@ -346,6 +354,7 @@ pub(super) async fn run_issue( host, active, updates, + operator_update_notify, launch, environment, excluded_environment, @@ -583,6 +592,7 @@ async fn try_run( host: &SessionHost, active: &ActiveSessions, updates: &mpsc::UnboundedSender, + operator_update_notify: &Notify, launch: &mut Option>, environment: BTreeMap, excluded_environment: BTreeSet, @@ -727,6 +737,9 @@ async fn try_run( let _cancel_on_drop = cancellation.clone().drop_guard(); let prompt_finished = CancellationToken::new(); let _finish_on_drop = prompt_finished.clone().drop_guard(); + let (operator_requests_tx, mut operator_requests_rx) = mpsc::channel(32); + let (operator_responses_tx, mut operator_responses_rx) = mpsc::channel(32); + let mut operator_waiters = HashMap::new(); let active_session = ActiveSession { handle: handle.clone(), cancellation: cancellation.clone(), @@ -735,6 +748,7 @@ async fn try_run( issue_id: issue.id.clone(), issue_identifier: issue.identifier.clone(), run_id: manifest.run_id.clone(), + operator_responses: operator_responses_tx, }; active .lock() @@ -758,16 +772,48 @@ async fn try_run( .latest_cursor .unwrap_or((handle.generation, 0)); let mut projection = RuntimeProjection::after(baseline); - let prompt = handle.prompt( + let prompt = handle.prompt_with_operator( manifest.run_id.clone(), manifest.attempt, prompt, cancellation.clone(), + Some(operator_requests_tx), ); tokio::pin!(prompt); + let mut operator_requests_open = true; + let mut operator_responses_open = true; let result = loop { tokio::select! { result = &mut prompt => break result, + operator = operator_requests_rx.recv(), if operator_requests_open => match operator { + Some(AcpOperatorEvent::Opened(mut request)) => { + request.interaction.run_id = manifest.run_id.clone(); + request.interaction.issue_id = issue.id.as_str().into(); + request.interaction.issue_identifier = issue.identifier.to_string(); + request.interaction.generation = handle.generation; + let request_id = request.interaction.request_id.clone(); + if operator_waiters.insert(request_id.clone(), request.reply).is_some() { + return Err("ACP callback repeated its request identity".into()); + } + if updates.send(WorkerUpdate::OperatorRequest { worker_id: run.worker_id.clone(), interaction: request.interaction }).is_ok() { + operator_update_notify.notify_one(); + } + } + Some(AcpOperatorEvent::Closed(request_id)) => { + operator_waiters.remove(&request_id); + if updates.send(WorkerUpdate::OperatorClosed { worker_id: run.worker_id.clone(), request_id }).is_ok() { + operator_update_notify.notify_one(); + } + } + // The owner drops this sender while cancelling a prompt that + // has not been submitted. Keep waiting for the prompt's + // terminal result so its cancellation evidence wins the race. + None => operator_requests_open = false, + }, + response = operator_responses_rx.recv(), if operator_responses_open => match response { + Some(response) => forward_operator_response(response, &mut operator_waiters), + None => operator_responses_open = false, + }, event = receiver.recv() => match event { Ok(SessionEvent::Gap { .. }) => return Err("ACP source stream lost an oversized frame; submission remains fenced".into()), Ok(event) => project_event(&event, &mut projection, &mut capability, &manifest.run_id, &run.worker_id, updates), @@ -870,12 +916,62 @@ fn project_event( } } +fn forward_operator_response( + response: OperatorResponseCommand, + waiters: &mut HashMap>, +) { + if response.delivery.is_cancelled() { + let _ = response.acknowledgement.send(false); + return; + } + let reply = crate::opensymphony_acp::AcpOperatorReply { + answer: response.answer, + acknowledgement: response.acknowledgement, + delivery: response.delivery, + }; + if let Some(waiter) = waiters.remove(&response.request_id) { + if let Err(reply) = waiter.send(reply) { + let _ = reply.acknowledgement.send(false); + } + } else { + let _ = reply.acknowledgement.send(false); + } +} + #[cfg(test)] mod tests { use super::*; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; + #[tokio::test] + async fn expired_operator_ack_fences_delayed_worker_consume() { + let delivery = crate::opensymphony_acp::AcpOperatorDeliveryFence::default(); + let (acknowledgement, mut received) = oneshot::channel(); + let response = OperatorResponseCommand { + request_id: "queued-request".into(), + answer: crate::opensymphony_gateway_schema::approval::OperatorAnswer::Cancel, + acknowledgement, + delivery: delivery.clone(), + }; + let (waiter, mut callback) = oneshot::channel(); + let mut waiters = HashMap::from([("queued-request".into(), waiter)]); + + assert!( + timeout(Duration::from_millis(10), &mut received) + .await + .is_err() + ); + assert!(delivery.cancel(), "failure receipt wins the delivery fence"); + forward_operator_response(response, &mut waiters); + assert!(!received.await.expect("negative acknowledgement")); + assert!(waiters.contains_key("queued-request")); + assert!( + callback.try_recv().is_err(), + "operator answer never reaches ACP" + ); + } + #[test] fn credential_scope_uses_platform_environment_name_rules() { let first = BTreeMap::from([("AGENT_TOKEN".into(), "first".into())]); diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index 2b1c95448..b209617c0 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -35,8 +35,8 @@ use crate::opensymphony_linear::LinearError; use crate::opensymphony_memory::MemoryVisibility; use crate::opensymphony_openhands::OpenHandsError; use crate::opensymphony_orchestrator::{ - IssueStateCategory, OrchestratorSnapshot, Scheduler, SchedulerConfig, SchedulerError, - TrackerBackend, WorkerBackend, WorkspaceBackend, + IssueStateCategory, OperatorResponseDelivery, OrchestratorSnapshot, Scheduler, SchedulerConfig, + SchedulerError, TrackerBackend, WorkerBackend, WorkspaceBackend, }; use crate::opensymphony_workspace::{WorkspaceError, checkout_credential_environment_variables}; use chrono::{DateTime, Utc}; @@ -68,6 +68,78 @@ use self::{ const MEMORY_SERVER_BIND_STATE: &str = ".opensymphony-memory-bind.json"; +enum RunWake { + Shutdown, + Server(Result, tokio::task::JoinError>), + OperatorCommand(Box), + OperatorUpdate, + TrackerTick, +} + +struct OperatorDeliveryCompleted { + interaction: crate::opensymphony_gateway_schema::approval::OperatorInteraction, + delivered: Result, + reply: tokio::sync::oneshot::Sender>, +} + +fn spawn_operator_delivery_completion( + delivery: OperatorResponseDelivery, + interaction: crate::opensymphony_gateway_schema::approval::OperatorInteraction, + reply: tokio::sync::oneshot::Sender>, + delivered_tx: tokio::sync::mpsc::Sender, + notify: Arc, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let completed = OperatorDeliveryCompleted { + interaction, + delivered: delivery.wait().await, + reply, + }; + if delivered_tx.send(completed).await.is_ok() { + notify.notify_one(); + } + }) +} + +async fn start_shutdown_listener() -> tokio::task::JoinHandle> { + use std::future::Future; + + let (armed_tx, armed_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let signal = tokio::signal::ctrl_c(); + tokio::pin!(signal); + let mut armed = Some(armed_tx); + std::future::poll_fn(|cx| { + let result = signal.as_mut().poll(cx); + if let Some(armed) = armed.take() { + let _ = armed.send(()); + } + result + }) + .await + }); + let _ = armed_rx.await; + task +} + +async fn next_run_wake( + shutdown_task: &mut tokio::task::JoinHandle>, + server_task: &mut tokio::task::JoinHandle>, + operator_commands_rx: &mut tokio::sync::mpsc::Receiver< + crate::opensymphony_gateway::OperatorCommand, + >, + operator_update_notify: &tokio::sync::Notify, + ticker: &mut tokio::time::Interval, +) -> RunWake { + tokio::select! { + _ = shutdown_task => RunWake::Shutdown, + result = server_task => RunWake::Server(result), + Some(command) = operator_commands_rx.recv() => RunWake::OperatorCommand(Box::new(command)), + _ = operator_update_notify.notified() => RunWake::OperatorUpdate, + _ = ticker.tick() => RunWake::TrackerTick, + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct MemoryServerBindState { schema_version: u32, @@ -1160,6 +1232,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { .map(checkout_credential_environment_variables) .unwrap_or_default(), ); + let operator_update_notify = worker.operator_update_notify(); let mut scheduler_config = SchedulerConfig::from_workflow(&runtime.workflow)?; scheduler_config.max_retry_attempts = runtime.retry_max_attempts; scheduler_config.repository_routing = runtime.repository_routing.clone(); @@ -1212,8 +1285,11 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { } else { None }; + let (operator_commands_tx, mut operator_commands_rx) = tokio::sync::mpsc::channel(64); + let (operator_deliveries_tx, mut operator_deliveries_rx) = tokio::sync::mpsc::channel(64); let server = GatewayServer::with_journal(store.clone(), gateway_journal.clone(), gateway_broker) + .with_operator_commands(operator_commands_tx) .with_linear_task_graph(build_optional_task_graph_client(&runtime.workflow)) .with_memory_config(server_memory_config) .with_harness_profiles(acp_profiles) @@ -1228,45 +1304,19 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { ) .with_terminal_states(terminal_state_set(&runtime.workflow)); let mut server_task = tokio::spawn(async move { server.serve(listener).await }); + // Keep SIGINT armed while bootstrap and scheduler mutations await. A + // short-lived ctrl_c future inside select can miss a signal during a tick. + let mut shutdown_task = start_shutdown_listener().await; let mut gateway_action_cursor = 0; - let bootstrap_snapshot = tokio::select! { - _ = tokio::signal::ctrl_c() => { - info!("received shutdown signal"); + let bootstrap_snapshot = match scheduler.bootstrap(now_timestamp()).await { + Ok(snapshot) => snapshot, + Err(error) => { server_task.abort(); + shutdown_task.abort(); shutdown_memory_server(&mut memory_server).await?; - if let Some(mut supervisor) = supervisor { - let _ = supervisor.stop(); - } - return Ok(()); + return Err(RunCommandError::SchedulerConfig(error)); } - result = &mut server_task => { - match result { - Ok(Ok(())) => { - shutdown_memory_server(&mut memory_server).await?; - if let Some(mut supervisor) = supervisor { - let _ = supervisor.stop(); - } - return Ok(()); - } - Ok(Err(error)) => { - shutdown_memory_server(&mut memory_server).await?; - return Err(RunCommandError::Serve(error)); - } - Err(error) => { - shutdown_memory_server(&mut memory_server).await?; - return Err(RunCommandError::Serve(io::Error::other(error.to_string()))); - } - } - } - result = scheduler.bootstrap(now_timestamp()) => match result { - Ok(snapshot) => snapshot, - Err(error) => { - server_task.abort(); - shutdown_memory_server(&mut memory_server).await?; - return Err(RunCommandError::SchedulerConfig(error)); - } - }, }; let startup_terminal_issues = terminal_issue_identifiers(&bootstrap_snapshot); let recovered_completed_parent_captures = scheduler.completed_subtree_cleanup_identifiers(); @@ -1302,39 +1352,108 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { - tokio::select! { - _ = tokio::signal::ctrl_c() => { + // Only wait for the next event inside select. Scheduler mutations can + // await after changing state and must run to completion once started. + let wake = next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut operator_commands_rx, + &operator_update_notify, + &mut ticker, + ) + .await; + match wake { + RunWake::Shutdown => { info!("received shutdown signal"); break; } - result = &mut server_task => { - match result { - Ok(Ok(())) => break, - Ok(Err(error)) => { - shutdown_memory_server(&mut memory_server).await?; - return Err(RunCommandError::Serve(error)); + RunWake::Server(result) => match result { + Ok(Ok(())) => break, + Ok(Err(error)) => { + shutdown_memory_server(&mut memory_server).await?; + return Err(RunCommandError::Serve(error)); + } + Err(error) => { + shutdown_memory_server(&mut memory_server).await?; + return Err(RunCommandError::Serve(io::Error::other(error.to_string()))); + } + }, + RunWake::OperatorCommand(command) => { + if !command.delivery.claim() { + let _ = command + .reply + .send(Err("operator response delivery timed out".into())); + continue; + } + let crate::opensymphony_gateway::OperatorCommand { + interaction, + answer, + reply, + .. + } = *command; + match scheduler + .begin_operator_response(&interaction, answer) + .await + { + Ok(delivery) => { + spawn_operator_delivery_completion( + delivery, + interaction, + reply, + operator_deliveries_tx.clone(), + operator_update_notify.clone(), + ); } Err(error) => { - shutdown_memory_server(&mut memory_server).await?; - return Err(RunCommandError::Serve(io::Error::other(error.to_string()))); + let _ = reply.send(Err(error)); } } } - result = async { - ticker.tick().await; + RunWake::OperatorUpdate => { + while let Ok(completed) = operator_deliveries_rx.try_recv() { + let result = scheduler + .complete_operator_response(&completed.interaction, completed.delivered); + let _ = completed.reply.send(result); + } + let observed_at = now_timestamp(); + let snapshot = match scheduler.drain_worker_updates(observed_at).await { + Ok(snapshot) => snapshot, + Err(error) => { + warn!(%error, "operator worker update failed"); + push_recent_event( + &mut recent_events, + RecentEventKind::Warning, + None, + format!("operator worker update failed: {error}"), + Utc::now(), + ); + scheduler.snapshot(observed_at) + } + }; + store + .publish(map_snapshot( + &snapshot, + runtime.workflow.config.workspace.root.as_path(), + &terminal_state_set(&runtime.workflow), + current_agent_server_status(&mut supervisor, agent_server_base_url), + current_memory_server_status(memory_server.as_ref()), + &recent_events, + )) + .await; + } + RunWake::TrackerTick => { let observed_at = now_timestamp(); let result = match apply_gateway_action_events( &mut scheduler, &gateway_journal, &mut gateway_action_cursor, observed_at, - ).await { + ) + .await + { Ok(()) => scheduler.tick(observed_at).await, Err(error) => Err(error), }; - (observed_at, result) - } => { - let (observed_at, result) = result; match result { Ok(snapshot) => { let current_terminal_issues = terminal_issue_identifiers(&snapshot); @@ -1354,22 +1473,25 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { ), Utc::now(), ); - store.publish(map_snapshot( - &snapshot, - runtime.workflow.config.workspace.root.as_path(), - &terminal_state_set(&runtime.workflow), - current_agent_server_status(&mut supervisor, agent_server_base_url), - current_memory_server_status(memory_server.as_ref()), - &recent_events, - )).await; + store + .publish(map_snapshot( + &snapshot, + runtime.workflow.config.workspace.root.as_path(), + &terminal_state_set(&runtime.workflow), + current_agent_server_status(&mut supervisor, agent_server_base_url), + current_memory_server_status(memory_server.as_ref()), + &recent_events, + )) + .await; if !auto_capture_candidates.is_empty() { // Parent completion and its exact commit evidence // can become durable in this scheduler tick. Load // bindings afterward so capture does not use the // pre-finalization controller snapshot. - let capture_bindings = super::memory::load_all_terminal_capture_bindings( - &runtime.workflow.config.workspace.root, - ); + let capture_bindings = + super::memory::load_all_terminal_capture_bindings( + &runtime.workflow.config.workspace.root, + ); let auto_capture_result = match capture_bindings { Ok(capture_bindings) => { super::memory::auto_capture_terminal( @@ -1431,7 +1553,8 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { recent_events: &mut recent_events, store: &store, }, - ).await; + ) + .await; } } Err(error) => { @@ -1444,14 +1567,16 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { Utc::now(), ); let snapshot = scheduler.snapshot(observed_at); - store.publish(map_snapshot( - &snapshot, - runtime.workflow.config.workspace.root.as_path(), - &terminal_state_set(&runtime.workflow), - current_agent_server_status(&mut supervisor, agent_server_base_url), - current_memory_server_status(memory_server.as_ref()), - &recent_events, - )).await; + store + .publish(map_snapshot( + &snapshot, + runtime.workflow.config.workspace.root.as_path(), + &terminal_state_set(&runtime.workflow), + current_agent_server_status(&mut supervisor, agent_server_base_url), + current_memory_server_status(memory_server.as_ref()), + &recent_events, + )) + .await; } } } @@ -1459,6 +1584,7 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { } server_task.abort(); + shutdown_task.abort(); shutdown_memory_server(&mut memory_server).await?; if let Some(mut supervisor) = supervisor { let _ = supervisor.stop(); @@ -2084,6 +2210,253 @@ mod tests { use super::*; use crate::opensymphony_memory::MemoryError; + #[tokio::test] + async fn pending_operator_flush_allows_worker_wake_and_shutdown() { + use crate::opensymphony_gateway_schema::approval::{ + OperatorInteraction, OperatorInteractionKind, + }; + + let mut ticker = interval(Duration::from_secs(300)); + let (_commands_tx, mut commands_rx) = tokio::sync::mpsc::channel(1); + let notify = Arc::new(tokio::sync::Notify::new()); + let (delivered_tx, mut delivered_rx) = tokio::sync::mpsc::channel(1); + let mut server_task = tokio::spawn(std::future::pending::>()); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let mut shutdown_task = tokio::spawn(async move { + shutdown_rx.await.map_err(io::Error::other)?; + Ok(()) + }); + assert!(matches!( + next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + ¬ify, + &mut ticker, + ) + .await, + RunWake::TrackerTick + )); + + let now = Utc::now(); + let interaction = OperatorInteraction { + request_id: "delayed-flush".into(), + run_id: "run-worker".into(), + issue_id: "issue".into(), + issue_identifier: "COE-612".into(), + session_id: "session".into(), + generation: 1, + rpc_id: "opaque-token".into(), + kind: OperatorInteractionKind::Permission, + title: "Permission".into(), + options: Vec::new(), + questions: Vec::new(), + plan: None, + requested_at: now, + expires_at: now + chrono::Duration::minutes(1), + }; + let (acknowledge, acknowledgement) = tokio::sync::oneshot::channel(); + let (reply, received) = tokio::sync::oneshot::channel(); + let delivery = OperatorResponseDelivery::new(async move { + acknowledgement.await.expect("worker flush acknowledgement") + }); + let delivery_task = spawn_operator_delivery_completion( + delivery, + interaction.clone(), + reply, + delivered_tx, + notify.clone(), + ); + notify.notify_one(); // an unrelated worker update arrives during the flush + assert!(matches!( + tokio::time::timeout( + Duration::from_millis(250), + next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + ¬ify, + &mut ticker, + ), + ) + .await + .expect("worker wake remains responsive"), + RunWake::OperatorUpdate + )); + assert!(delivered_rx.try_recv().is_err()); + shutdown_tx.send(()).expect("signal shutdown"); + assert!(matches!( + tokio::time::timeout( + Duration::from_millis(250), + next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + ¬ify, + &mut ticker, + ), + ) + .await + .expect("shutdown remains responsive"), + RunWake::Shutdown + )); + acknowledge.send(Ok(true)).expect("release flush"); + delivery_task.await.expect("delivery completion task"); + let completed = delivered_rx.recv().await.expect("actor completion message"); + assert_eq!(completed.interaction, interaction); + assert_eq!(completed.delivered, Ok(true)); + completed.reply.send(Ok(())).expect("gateway receipt"); + received + .await + .expect("authoritative receipt") + .expect("accepted"); + server_task.abort(); + } + + #[tokio::test] + async fn selected_tracker_tick_completes_before_queued_operator_wakes() { + use crate::opensymphony_gateway::OperatorCommand; + use crate::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteraction, OperatorInteractionKind, + }; + + let mut ticker = interval(Duration::from_secs(300)); + let (commands_tx, mut commands_rx) = tokio::sync::mpsc::channel(1); + let operator_notify = Arc::new(tokio::sync::Notify::new()); + let mut server_task = tokio::spawn(std::future::pending::>()); + let mut shutdown_task = tokio::spawn(std::future::pending::>()); + assert!(matches!( + next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + &operator_notify, + &mut ticker, + ) + .await, + RunWake::TrackerTick + )); + + let (tick_entered_tx, tick_entered_rx) = tokio::sync::oneshot::channel(); + let (tick_release_tx, tick_release_rx) = tokio::sync::oneshot::channel(); + let signal = operator_notify.clone(); + let queued_wakes = tokio::spawn(async move { + tick_entered_rx.await.expect("tick entered"); + signal.notify_one(); + let now = Utc::now(); + let (reply, _received) = tokio::sync::oneshot::channel(); + commands_tx + .send(OperatorCommand { + interaction: OperatorInteraction { + request_id: "request".into(), + run_id: "run".into(), + issue_id: "issue".into(), + issue_identifier: "COE-612".into(), + session_id: "session".into(), + generation: 1, + rpc_id: "0".into(), + kind: OperatorInteractionKind::Permission, + title: "Permission".into(), + options: Vec::new(), + questions: Vec::new(), + plan: None, + requested_at: now, + expires_at: now + chrono::Duration::minutes(1), + }, + answer: OperatorAnswer::Cancel, + reply, + delivery: crate::opensymphony_gateway::OperatorCommandFence::default(), + }) + .await + .expect("queue operator command"); + tick_release_tx.send(()).expect("release tick"); + }); + let selected_tick = async { + tick_entered_tx.send(()).expect("start tick"); + tick_release_rx.await.expect("tick release"); + }; + selected_tick.await; + queued_wakes.await.expect("queued wakes"); + + let first = next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + &operator_notify, + &mut ticker, + ) + .await; + let second = next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + &operator_notify, + &mut ticker, + ) + .await; + assert!(matches!( + &first, + RunWake::OperatorCommand(_) | RunWake::OperatorUpdate + )); + assert!(matches!( + &second, + RunWake::OperatorCommand(_) | RunWake::OperatorUpdate + )); + assert_ne!( + matches!(&first, RunWake::OperatorCommand(_)), + matches!(&second, RunWake::OperatorCommand(_)), + "both queued event types remain available after the tick" + ); + server_task.abort(); + shutdown_task.abort(); + } + + #[tokio::test] + async fn shutdown_queued_during_tick_wakes_after_tick_completes() { + let mut ticker = interval(Duration::from_secs(300)); + let (_commands_tx, mut commands_rx) = tokio::sync::mpsc::channel(1); + let operator_notify = tokio::sync::Notify::new(); + let mut server_task = tokio::spawn(std::future::pending::>()); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let mut shutdown_task = tokio::spawn(async move { + shutdown_rx.await.map_err(io::Error::other)?; + Ok(()) + }); + assert!(matches!( + next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + &operator_notify, + &mut ticker, + ) + .await, + RunWake::TrackerTick + )); + + // Model a long scheduler mutation: the signal listener must remain + // armed while the selected tick is in progress. + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let queue_shutdown = tokio::spawn(async move { + shutdown_tx.send(()).expect("queue shutdown"); + release_tx.send(()).expect("release tick"); + }); + release_rx.await.expect("tick completed"); + queue_shutdown.await.expect("queued signal"); + assert!(matches!( + next_run_wake( + &mut shutdown_task, + &mut server_task, + &mut commands_rx, + &operator_notify, + &mut ticker, + ) + .await, + RunWake::Shutdown + )); + server_task.abort(); + } + fn issue_set(keys: &[&str]) -> BTreeSet { keys.iter().map(|key| key.to_string()).collect() } diff --git a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs index edc53dcaf..0f14800c9 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/snapshot.rs @@ -69,6 +69,7 @@ pub(super) fn map_snapshot( terminal_states, generated_at, snapshot.hierarchy.get(issue.issue.id.as_str()), + &snapshot.operator_interactions, ) }) .collect(), @@ -100,6 +101,7 @@ fn map_issue( terminal_states: &HashSet, generated_at: DateTime, hierarchy: Option<&crate::opensymphony_domain::HierarchyStateSnapshot>, + operator_interactions: &[crate::opensymphony_gateway_schema::approval::OperatorInteraction], ) -> IssueSnapshot { let runtime_state = match issue.runtime.state { SchedulerStatus::Running | SchedulerStatus::Claimed => IssueRuntimeState::Running, @@ -243,6 +245,11 @@ fn map_issue( }); IssueSnapshot { + operator_interactions: operator_interactions + .iter() + .filter(|request| request.issue_id == issue.issue.id.as_str()) + .cloned() + .collect(), harness_capability: issue .conversation .as_ref() diff --git a/crates/opensymphony-cli/tests/tui.rs b/crates/opensymphony-cli/tests/tui.rs index a5c3811d8..95232bb97 100644 --- a/crates/opensymphony-cli/tests/tui.rs +++ b/crates/opensymphony-cli/tests/tui.rs @@ -60,6 +60,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 50_000, }, issues: vec![IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-271".to_owned(), title: "FrankenTUI operator client".to_owned(), diff --git a/crates/opensymphony-codex/src/lib.rs b/crates/opensymphony-codex/src/lib.rs index 842a489bb..c598e6c4d 100644 --- a/crates/opensymphony-codex/src/lib.rs +++ b/crates/opensymphony-codex/src/lib.rs @@ -1061,6 +1061,7 @@ pub fn codex_approval_request_from_event( .join(":"); Some(ApprovalRequest { + operator_interaction: None, schema_version: SchemaVersion::v1(), approval_id, run_id: run_id.clone(), diff --git a/crates/opensymphony-control/src/lib.rs b/crates/opensymphony-control/src/lib.rs index 4222acb7d..dc9250875 100644 --- a/crates/opensymphony-control/src/lib.rs +++ b/crates/opensymphony-control/src/lib.rs @@ -455,6 +455,7 @@ mod tests { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), diff --git a/crates/opensymphony-control/tests/control_plane.rs b/crates/opensymphony-control/tests/control_plane.rs index 37cef2a93..48bf2ed99 100644 --- a/crates/opensymphony-control/tests/control_plane.rs +++ b/crates/opensymphony-control/tests/control_plane.rs @@ -45,6 +45,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), diff --git a/crates/opensymphony-domain/src/control_plane.rs b/crates/opensymphony-domain/src/control_plane.rs index 2b8885886..1cee91435 100644 --- a/crates/opensymphony-domain/src/control_plane.rs +++ b/crates/opensymphony-domain/src/control_plane.rs @@ -87,6 +87,9 @@ pub struct ControlPlaneMetricsSnapshot { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ControlPlaneIssueSnapshot { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operator_interactions: + Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub harness_capability: Option, diff --git a/crates/opensymphony-domain/src/lib.rs b/crates/opensymphony-domain/src/lib.rs index b43bcc3a4..edcb32759 100644 --- a/crates/opensymphony-domain/src/lib.rs +++ b/crates/opensymphony-domain/src/lib.rs @@ -1174,6 +1174,37 @@ mod tests { assert_eq!(snapshot.runtime.stalled_at, Some(ts(360))); } + #[test] + fn delayed_operator_resolution_restarts_idle_clock_without_extending_absolute_cap() { + let issue = sample_issue(); + let workspace = sample_workspace(); + let mut execution = IssueExecution::new(issue.clone(), ts(30)); + must(execution.attach_workspace(workspace.clone())); + let run = sample_run(&issue, &workspace, None, ts(40)); + let execution = must(execution.claim(run)); + let mut execution = must(execution.start_running( + ts(50), + super::DurationMs::new(100), + Some(sample_conversation(false)), + )); + // Both an answered request and a callback closed by the peer use this + // transition; each may arrive after the prior idle deadline. + execution.observe_operator_resolution(ts(500)); + assert_eq!(execution.snapshot().runtime.stalled_at, Some(ts(600))); + execution.observe_operator_resolution(ts(800)); + assert_eq!(execution.snapshot().runtime.stalled_at, Some(ts(900))); + + let mut capped = super::StallMetadata::with_runtime_cap( + ts(50), + super::DurationMs::new(100), + Some(super::DurationMs::new(700)), + ); + capped.observe_activity(ts(500)); + assert_eq!(capped.stalled_at, ts(600)); + capped.observe_activity(ts(800)); + assert_eq!(capped.stalled_at, ts(750)); + } + #[test] fn attach_workspace_rejects_rebinding_to_different_identity() { let issue = sample_issue(); diff --git a/crates/opensymphony-domain/src/snapshot.rs b/crates/opensymphony-domain/src/snapshot.rs index aa3e773ab..3206c9010 100644 --- a/crates/opensymphony-domain/src/snapshot.rs +++ b/crates/opensymphony-domain/src/snapshot.rs @@ -233,6 +233,9 @@ pub struct OrchestratorSnapshot { pub generated_at: TimestampMs, pub daemon: DaemonSnapshot, pub issues: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operator_interactions: + Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub hierarchy: BTreeMap, } @@ -260,6 +263,7 @@ impl OrchestratorSnapshot { ..daemon }, issues, + operator_interactions: Vec::new(), hierarchy: BTreeMap::new(), } } diff --git a/crates/opensymphony-domain/src/state_machine.rs b/crates/opensymphony-domain/src/state_machine.rs index 50d7d2b38..efdb028cd 100644 --- a/crates/opensymphony-domain/src/state_machine.rs +++ b/crates/opensymphony-domain/src/state_machine.rs @@ -600,6 +600,15 @@ impl IssueExecution { } } + /// A delivered or closed operator request resumes the worker's idle clock. + /// This records liveness without fabricating an agent runtime event or + /// changing the absolute runtime cap anchored to the run's start. + pub fn observe_operator_resolution(&mut self, observed_at: TimestampMs) { + if let SchedulerState::Running { stall, .. } = &mut self.state { + stall.observe_activity(observed_at); + } + } + pub fn queue_retry( mut self, retry: RetryEntry, diff --git a/crates/opensymphony-domain/tests/snapshot_serialization.rs b/crates/opensymphony-domain/tests/snapshot_serialization.rs index dfbb85672..a1758b252 100644 --- a/crates/opensymphony-domain/tests/snapshot_serialization.rs +++ b/crates/opensymphony-domain/tests/snapshot_serialization.rs @@ -41,6 +41,7 @@ fn fixture() -> SnapshotEnvelope { total_cost_micros: 250_000, }, issues: vec![IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-269".to_owned(), title: "Control-plane API and snapshot store".to_owned(), diff --git a/crates/opensymphony-gateway-schema/src/action.rs b/crates/opensymphony-gateway-schema/src/action.rs index e1440f526..f4cdd8905 100644 --- a/crates/opensymphony-gateway-schema/src/action.rs +++ b/crates/opensymphony-gateway-schema/src/action.rs @@ -90,6 +90,8 @@ pub enum ActionKind { TransitionIssue, CreateFollowup, ApprovalDecision, + InputResponse, + PlanDecision, PublishPlan, /// Create or update a Linear project milestone. TaskGraphMilestone, @@ -119,6 +121,8 @@ impl std::fmt::Display for ActionKind { ActionKind::TransitionIssue => "transition_issue", ActionKind::CreateFollowup => "create_followup", ActionKind::ApprovalDecision => "approval_decision", + ActionKind::InputResponse => "input_response", + ActionKind::PlanDecision => "plan_decision", ActionKind::PublishPlan => "publish_plan", ActionKind::TaskGraphMilestone => "task_graph_milestone", ActionKind::TaskGraphIssue => "task_graph_issue", @@ -185,6 +189,9 @@ impl ActionKind { ExpectedFollowup::ActionCompletion, ExpectedFollowup::StateTransition, ], + ActionKind::InputResponse | ActionKind::PlanDecision => { + vec![ExpectedFollowup::ActionCompletion] + } ActionKind::PublishPlan => vec![ ExpectedFollowup::ActionCompletion, ExpectedFollowup::JournalUpdate, diff --git a/crates/opensymphony-gateway-schema/src/approval.rs b/crates/opensymphony-gateway-schema/src/approval.rs index 390bc1d5f..ccfbc5ded 100644 --- a/crates/opensymphony-gateway-schema/src/approval.rs +++ b/crates/opensymphony-gateway-schema/src/approval.rs @@ -3,6 +3,74 @@ use serde::{Deserialize, Serialize}; use super::version::SchemaVersion; +/// A live ACP callback. This is deliberately ephemeral: a restart cannot +/// restore an operator decision without the same live RPC responder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorInteraction { + pub request_id: String, + pub run_id: String, + pub issue_id: String, + pub issue_identifier: String, + pub session_id: String, + pub generation: u64, + /// Generated public binding token; the original peer RPC ID stays private + /// inside the ACP responder. Clients echo this token unchanged. + pub rpc_id: String, + pub kind: OperatorInteractionKind, + pub title: String, + pub options: Vec, + pub questions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, + pub requested_at: DateTime, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperatorInteractionKind { + Permission, + Question, + PlanApproval, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorOption { + pub id: String, + pub label: String, + pub kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorQuestion { + pub id: String, + pub prompt: String, + pub options: Vec, + pub allow_multiple: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorQuestionAnswer { + pub question_id: String, + pub selected_option_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum OperatorAnswer { + Permission { + option_id: String, + }, + Question { + answers: Vec, + }, + Plan { + accepted: bool, + }, + Decline, + Cancel, +} + /// Approval request exposed by the gateway for human-in-the-loop actions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApprovalRequest { @@ -15,6 +83,8 @@ pub struct ApprovalRequest { pub description: String, pub proposed_action: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub operator_interaction: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub actor: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_context: Option, diff --git a/crates/opensymphony-gateway-schema/src/capability.rs b/crates/opensymphony-gateway-schema/src/capability.rs index 6d192da38..8cc528d07 100644 --- a/crates/opensymphony-gateway-schema/src/capability.rs +++ b/crates/opensymphony-gateway-schema/src/capability.rs @@ -338,13 +338,13 @@ impl HarnessCapability { runtime_contract_version: Some("acp-v1".into()), actions: HarnessActionCapability { start_run: true, send_user_message: false, retry: true, cancel: true, - pause: false, resume: false, approve: false, reject: false, comment: false, + pause: false, resume: false, approve: true, reject: true, comment: false, }, event_streams: HarnessEventStreamCapability { runtime_events: true, terminal_frames: false, replay_from_cursor: false, raw_payload_refs: true, delivery_modes: vec!["json_rpc_notifications".into()], }, - approvals: HarnessApprovalCapability { tool_approval: false, human_decision: false, policy_metadata: false }, + approvals: HarnessApprovalCapability { tool_approval: true, human_decision: true, policy_metadata: false }, model_settings: HarnessModelSettingsCapability { api_compatible_settings: false, subscription_credentials: false, per_run_overrides: true, credential_reference_kinds: vec!["env".into()], @@ -357,8 +357,8 @@ impl HarnessCapability { history: HarnessHistoryCapability { fetch_history: false, reconcile_after_ready: false, reconnect_and_replay: false, preserve_unknown_events: true, }, - notes: vec!["ACP v1 host-owned stdio sessions; profile preflight and negotiated run support are separate from adapter availability.".into()], - feature_gaps: vec!["Operator responses require the operator-request routing implementation.".into(), "Context restoration requires negotiated session/load or session/resume support; uncertain prompt delivery remains fenced.".into()], + notes: vec!["ACP v1 host-owned stdio sessions; profile preflight and negotiated run support are separate from adapter availability.".into(), "Operator responses support offered permission options and bounded required choice forms (string and string-array enums).".into()], + feature_gaps: vec!["Free-text, numeric, optional, constrained, request-scoped and URL elicitation are unavailable; unsupported requests are cancelled.".into(), "Cursor vendor methods require version-tested registration in COE-613.".into(), "Context restoration requires negotiated session/load or session/resume support; uncertain prompt delivery remains fenced.".into()], } } diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 42d7b7ab3..091e0f26d 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -687,6 +687,7 @@ fn terminal_snapshot_roundtrips() { #[test] fn approval_request_roundtrips() { let req = ApprovalRequest { + operator_interaction: None, schema_version: SchemaVersion::v1(), approval_id: "apr-1".into(), run_id: "run-1".into(), @@ -810,7 +811,13 @@ fn harness_capability_roundtrips_available_and_future_adapters() { assert_eq!(back.len(), 4); assert_eq!(back[3].kind, "acp"); assert!(back[3].available && back[3].actions.start_run); - assert!(!back[3].approvals.human_decision); + assert!(back[3].approvals.human_decision && back[3].actions.approve); + assert!( + back[3] + .notes + .iter() + .any(|note| note.contains("choice forms")) + ); assert!(back[0].available); assert_eq!(back[1].kind, "codex_app_server"); assert_eq!(back[1].transport.protocol, "json_rpc_2_0"); diff --git a/crates/opensymphony-gateway/src/action_handler.rs b/crates/opensymphony-gateway/src/action_handler.rs index 4914b11b4..ada51cc0d 100644 --- a/crates/opensymphony-gateway/src/action_handler.rs +++ b/crates/opensymphony-gateway/src/action_handler.rs @@ -145,6 +145,10 @@ impl ActionHandler { .map_or_else(PermissionResult::local, |checker| checker.check(action)) } + pub fn permission_for_action(&self, action: &ActionDispatch) -> PermissionResult { + self.permission_for(action) + } + /// Core dispatch logic without idempotency locking. async fn dispatch_unlocked( &self, @@ -178,6 +182,9 @@ impl ActionHandler { ActionKind::TransitionIssue => validate_generic(&action, issue.as_ref(), &action_id), ActionKind::CreateFollowup => validate_generic(&action, issue.as_ref(), &action_id), ActionKind::ApprovalDecision => validate_generic(&action, issue.as_ref(), &action_id), + ActionKind::InputResponse | ActionKind::PlanDecision => { + validate_generic(&action, issue.as_ref(), &action_id) + } ActionKind::PublishPlan => validate_generic(&action, issue.as_ref(), &action_id), ActionKind::TaskGraphMilestone => validate_task_graph(&action, &action_id), ActionKind::TaskGraphIssue => validate_task_graph(&action, &action_id), @@ -486,11 +493,9 @@ fn validate_resume( /// Generic action validation for actions that do not require runtime state gating. /// /// Actions validated here (`OpenWorkspace`, `Debug`, `TransitionIssue`, `CreateFollowup`, -/// `ApprovalDecision`, `PublishPlan`) are inherently safe because they operate on the -/// issue tracker, planning layer, or local UI rather than the active harness runtime. -/// They do not mutate scheduler state and are therefore accepted for any valid issue -/// snapshot. If a future action needs runtime state gating, it should be promoted from -/// `validate_generic` to a dedicated validator (e.g., `validate_pause`, `validate_resume`). +/// `ApprovalDecision`, `InputResponse`, `PlanDecision`, `PublishPlan`) have no +/// intrinsic runtime-state requirement here. The ACP operator path performs its +/// live request/answer validation and worker delivery before this audit receipt. fn validate_generic( action: &ActionDispatch, issue: Option<&ControlPlaneIssueSnapshot>, diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index 3f658b6d5..74c77fc67 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -4,7 +4,10 @@ use std::{ ffi::OsStr, path::{Path as StdPath, PathBuf}, process::Command, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicU8, Ordering}, + }, time::{Duration, Instant}, }; @@ -289,7 +292,44 @@ impl Drop for BrokerConnectionGuard { } /// Shared state for the gateway server. +pub struct OperatorCommand { + pub interaction: crate::opensymphony_gateway_schema::approval::OperatorInteraction, + pub answer: crate::opensymphony_gateway_schema::approval::OperatorAnswer, + pub reply: tokio::sync::oneshot::Sender>, + pub delivery: OperatorCommandFence, +} + +/// The gateway's failure receipt and the run loop's scheduler mutation race +/// through this fence. A queued command can never be applied after cancellation. +#[derive(Clone, Default)] +pub struct OperatorCommandFence(Arc); + +impl OperatorCommandFence { + pub fn cancel(&self) -> bool { + self.0 + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + pub fn claim(&self) -> bool { + self.0 + .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } +} + +struct OperatorCommandReceiptGuard(OperatorCommandFence); + +impl Drop for OperatorCommandReceiptGuard { + fn drop(&mut self) { + // An HTTP handler can disappear before its explicit deadline when the + // client disconnects. Only a run-loop claim can outrun this cancel. + self.0.cancel(); + } +} + pub struct GatewayState { + pub operator_commands: Option>, pub store: SnapshotStore, pub journal: InMemoryEventJournal, pub broker: StreamBroker, @@ -321,6 +361,7 @@ pub struct GatewayState { impl Clone for GatewayState { fn clone(&self) -> Self { Self { + operator_commands: self.operator_commands.clone(), store: self.store.clone(), journal: self.journal.clone(), broker: self.broker.clone(), @@ -593,6 +634,7 @@ impl axum::extract::FromRef for PrUrls { /// V1 gateway server that exposes stable public DTO endpoints /// on top of the internal control-plane `SnapshotStore`. pub struct GatewayServer { + operator_commands: Option>, store: SnapshotStore, journal: InMemoryEventJournal, broker: StreamBroker, @@ -611,6 +653,7 @@ pub struct GatewayServer { impl Clone for GatewayServer { fn clone(&self) -> Self { Self { + operator_commands: self.operator_commands.clone(), store: self.store.clone(), journal: self.journal.clone(), broker: self.broker.clone(), @@ -670,6 +713,7 @@ impl GatewayServer { let journal = InMemoryEventJournal::new(GATEWAY_JOURNAL_CAPACITY, GATEWAY_SUBSCRIBER_CAPACITY); Self { + operator_commands: None, journal: journal.clone(), broker: StreamBroker::new(journal.clone()), store, @@ -692,6 +736,7 @@ impl GatewayServer { broker: StreamBroker, ) -> Self { Self { + operator_commands: None, store, journal, broker, @@ -707,6 +752,14 @@ impl GatewayServer { } } + pub fn with_operator_commands( + mut self, + commands: tokio::sync::mpsc::Sender, + ) -> Self { + self.operator_commands = Some(commands); + self + } + pub fn with_harness_profiles( mut self, profiles: Vec, @@ -789,6 +842,7 @@ impl GatewayServer { pub fn router(&self) -> Router { let terminal_log_store = Arc::new(tokio::sync::RwLock::new(TerminalLogStore::new())); let state = GatewayState { + operator_commands: self.operator_commands.clone(), store: self.store.clone(), journal: self.journal.clone(), broker: self.broker.clone(), @@ -912,6 +966,7 @@ impl GatewayServer { .route("/api/v1/runs/{run_id}/diffs", get(get_run_diffs)) .route("/api/v1/runs/{run_id}/validation", get(get_run_validation)) .route("/api/v1/runs/{run_id}/approvals", get(get_run_approvals)) + .route("/api/v1/runs/{run_id}/inputs", get(get_run_inputs)) .route("/api/v1/runs/{run_id}/timeline", get(get_run_timeline)) .route("/api/v1/runs/{run_id}/logs", get(get_run_logs)) .route( @@ -2876,6 +2931,21 @@ async fn dashboard_snapshot(State(state): State) -> Json>, + delivery: &OperatorCommandFence, + deadline: Duration, +) -> Result, ()> { + tokio::pin!(received); + match tokio::time::timeout(deadline, &mut received).await { + Ok(result) => result.map_err(|_| ()), + Err(_) if delivery.cancel() => Err(()), + // The run loop has already claimed the command. Its result is now + // authoritative; a timeout must not report failure before application. + Err(_) => received.await.map_err(|_| ()), + } +} + /// POST /api/v1/actions/dispatch /// /// Validates the action against the current snapshot state, publishes an audit @@ -2886,6 +2956,66 @@ async fn dispatch_action( Json(action): Json, ) -> impl IntoResponse { let envelope = state.store.current().await; + if matches!( + action.action_kind, + ActionKind::InputResponse | ActionKind::PlanDecision + ) || (action.action_kind == ActionKind::ApprovalDecision + && action.target_entity.entity_kind == EntityKind::Run) + { + let reject = |reason: String| { + ActionReceipt::rejected( + uuid::Uuid::new_v4().to_string(), + action.correlation_id.clone(), + action.action_kind, + reason, + ) + }; + let permission = state.action_handler.permission_for_action(&action); + if !permission.allowed { + let receipt = reject("permission denied for operator response".into()) + .with_permission(permission); + return (StatusCode::FORBIDDEN, Json(receipt)); + } + let (interaction, answer) = match operator_action(&envelope, &action) { + Ok(value) => value, + Err(error) => { + let receipt = reject(error); + return (dispatch_rejection_status(&receipt), Json(receipt)); + } + }; + let Some(commands) = &state.operator_commands else { + let receipt = reject("operator response path is unavailable".into()); + return (StatusCode::SERVICE_UNAVAILABLE, Json(receipt)); + }; + let receipt_guard = OperatorCommandReceiptGuard(OperatorCommandFence::default()); + let (reply, received) = tokio::sync::oneshot::channel(); + if commands + .try_send(OperatorCommand { + interaction, + answer, + reply, + delivery: receipt_guard.0.clone(), + }) + .is_err() + { + let receipt = reject("operator response path is unavailable".into()); + return (StatusCode::SERVICE_UNAVAILABLE, Json(receipt)); + } + match await_operator_delivery(received, &receipt_guard.0, Duration::from_secs(30)).await { + Ok(Ok(())) => { + let receipt = state.action_handler.dispatch(action, &envelope).await; + return (StatusCode::OK, Json(receipt)); + } + Ok(Err(error)) => { + let receipt = reject(error); + return (StatusCode::CONFLICT, Json(receipt)); + } + Err(()) => { + let receipt = reject("operator response delivery timed out".into()); + return (StatusCode::SERVICE_UNAVAILABLE, Json(receipt)); + } + } + } let receipt = state.action_handler.dispatch(action, &envelope).await; match receipt.status { @@ -2897,6 +3027,138 @@ async fn dispatch_action( } } +fn operator_action( + envelope: &SnapshotEnvelope, + action: &ActionDispatch, +) -> Result< + ( + crate::opensymphony_gateway_schema::approval::OperatorInteraction, + crate::opensymphony_gateway_schema::approval::OperatorAnswer, + ), + String, +> { + use crate::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteractionKind, OperatorQuestionAnswer, + }; + if action.target_entity.entity_kind != EntityKind::Run + || action.idempotency_key.is_some() + || action.correlation_id.trim().is_empty() + { + return Err( + "operator response requires a run target, unique correlation and no replay key".into(), + ); + } + let payload = action + .payload + .as_ref() + .and_then(serde_json::Value::as_object) + .ok_or("operator response payload is missing")?; + let issue = find_issue_snapshot(envelope, &action.target_entity.entity_id) + .ok_or("target run not found")?; + let request_id = payload + .get("request_id") + .and_then(serde_json::Value::as_str) + .ok_or("request_id is missing")?; + let request = issue + .operator_interactions + .iter() + .find(|request| request.request_id == request_id) + .ok_or("operator request is stale or already answered")?; + if request.issue_id + != payload + .get("issue_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + || request.run_id + != payload + .get("run_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + || request.session_id + != payload + .get("session_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + || Some(request.generation) + != payload + .get("generation") + .and_then(serde_json::Value::as_u64) + || payload.get("rpc_id").and_then(serde_json::Value::as_str) + != Some(request.rpc_id.as_str()) + || request.expires_at <= Utc::now() + { + return Err("operator request binding is stale or expired".into()); + } + let answer = match (request.kind, action.action_kind) { + (OperatorInteractionKind::Permission, ActionKind::ApprovalDecision) => { + let decision = payload + .get("decision") + .and_then(serde_json::Value::as_str) + .ok_or("approval decision is missing")?; + if decision == "cancelled" { + OperatorAnswer::Cancel + } else { + if !matches!(decision, "approved" | "rejected") { + return Err("invalid approval decision".into()); + } + let id = payload + .get("option_id") + .and_then(serde_json::Value::as_str) + .ok_or("permission option_id is required")?; + let option = request.options.iter().find(|option| { + option.id == id + && match decision { + "approved" => { + matches!(option.kind.as_str(), "allow_once" | "allow_always") + } + _ => matches!(option.kind.as_str(), "reject_once" | "reject_always"), + } + }); + OperatorAnswer::Permission { + option_id: option + .ok_or("offered permission option is missing or invalid")? + .id + .clone(), + } + } + } + (OperatorInteractionKind::Question, ActionKind::InputResponse) => { + match payload + .get("outcome") + .and_then(serde_json::Value::as_str) + .ok_or("question outcome is missing")? + { + "answered" => OperatorAnswer::Question { + answers: serde_json::from_value::>( + payload + .get("answers") + .cloned() + .ok_or("question answers are missing")?, + ) + .map_err(|_| "invalid question answer structure")?, + }, + "declined" => OperatorAnswer::Decline, + "cancelled" => OperatorAnswer::Cancel, + _ => return Err("invalid question outcome".into()), + } + } + (OperatorInteractionKind::PlanApproval, ActionKind::PlanDecision) => { + match payload + .get("decision") + .and_then(serde_json::Value::as_str) + .ok_or("plan decision is missing")? + { + "approved" => OperatorAnswer::Plan { accepted: true }, + "rejected" => OperatorAnswer::Plan { accepted: false }, + "cancelled" => OperatorAnswer::Cancel, + _ => return Err("invalid plan decision".into()), + } + } + _ => return Err("response kind does not match pending interaction".into()), + }; + Ok((request.clone(), answer)) +} + /// Map rejection reasons to granular HTTP status codes so API consumers can /// distinguish retryable vs. non-retryable failures without parsing the receipt. fn dispatch_rejection_status(receipt: &ActionReceipt) -> StatusCode { @@ -5370,11 +5632,71 @@ async fn get_run_approvals( StatusCode::OK, Json(ApprovalListPage { run_id: issue.identifier.clone(), - approvals: Vec::new(), + approvals: issue + .operator_interactions + .iter() + .filter_map(|request| { + use crate::opensymphony_gateway_schema::approval::{ + ApprovalKind, ApprovalStatus, OperatorInteractionKind, + }; + let kind = match request.kind { + OperatorInteractionKind::Permission => ApprovalKind::ToolUse, + OperatorInteractionKind::PlanApproval => ApprovalKind::PlanPublish, + OperatorInteractionKind::Question => return None, + }; + Some(ApprovalRequest { + schema_version: SchemaVersion::v1(), + approval_id: request.request_id.clone(), + run_id: request.run_id.clone(), + issue_id: request.issue_id.clone(), + kind, + title: request.title.clone(), + description: request + .plan + .clone() + .unwrap_or_else(|| "ACP tool permission".into()), + proposed_action: None, + operator_interaction: Some(request.clone()), + actor: None, + target_context: None, + risk_summary: None, + requested_at: request.requested_at, + expires_at: Some(request.expires_at), + status: ApprovalStatus::Pending, + correlation_id: request.request_id.clone(), + decided_at: None, + }) + }) + .collect(), }), ) } +async fn get_run_inputs( + State(store): State, + AxumPath(run_id): AxumPath, +) -> impl IntoResponse { + let envelope = store.current().await; + let Some(issue) = find_issue_snapshot(&envelope, &run_id) else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"run_id":run_id,"inputs":[]})), + ); + }; + let inputs = issue + .operator_interactions + .iter() + .filter(|request| { + request.kind + == crate::opensymphony_gateway_schema::approval::OperatorInteractionKind::Question + }) + .collect::>(); + ( + StatusCode::OK, + Json(serde_json::json!({"run_id":issue.identifier,"inputs":inputs})), + ) +} + async fn get_run_timeline( State(state): State, AxumPath(run_id): AxumPath, @@ -5789,6 +6111,85 @@ mod tests { }; use crate::opensymphony_memory::MemoryRepositorySource; + #[tokio::test] + async fn timed_out_gateway_command_cannot_apply_after_delayed_consume() { + use crate::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteraction, OperatorInteractionKind, + }; + + let now = Utc::now(); + let delivery = OperatorCommandFence::default(); + let (reply, received) = tokio::sync::oneshot::channel(); + let (commands, mut queued) = tokio::sync::mpsc::channel(1); + assert!( + commands + .try_send(OperatorCommand { + interaction: OperatorInteraction { + request_id: "queued".into(), + run_id: "run".into(), + issue_id: "issue".into(), + issue_identifier: "COE-612".into(), + session_id: "session".into(), + generation: 1, + rpc_id: "1".into(), + kind: OperatorInteractionKind::Permission, + title: "Permission".into(), + options: Vec::new(), + questions: Vec::new(), + plan: None, + requested_at: now, + expires_at: now + chrono::Duration::minutes(1), + }, + answer: OperatorAnswer::Cancel, + reply, + delivery: delivery.clone(), + }) + .is_ok(), + "queue before busy scheduler tick" + ); + + assert!( + await_operator_delivery(received, &delivery, Duration::from_millis(10)) + .await + .is_err() + ); + let command = queued.recv().await.expect("delayed command"); + assert!( + !command.delivery.claim(), + "a 503 fences scheduler application" + ); + assert!( + command.reply.send(Ok(())).is_err(), + "failure receipt already closed" + ); + } + + #[tokio::test] + async fn gateway_waits_for_ack_after_scheduler_claims_command() { + let delivery = OperatorCommandFence::default(); + let (reply, received) = tokio::sync::oneshot::channel(); + assert!(delivery.claim()); + let send = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + reply.send(Ok(())).expect("acknowledge claimed command"); + }); + assert!( + await_operator_delivery(received, &delivery, Duration::from_millis(1)) + .await + .expect("claim waits for actual ack") + .is_ok() + ); + send.await.expect("ack task"); + } + + #[test] + fn dropped_gateway_receipt_cancels_unclaimed_command() { + let receipt_guard = OperatorCommandReceiptGuard(OperatorCommandFence::default()); + let delayed_command = receipt_guard.0.clone(); + drop(receipt_guard); + assert!(!delayed_command.claim()); + } + #[test] fn completed_tasks_sort_puts_undated_rows_last_in_both_directions() { fn row(issue_key: &str, completed_at: Option<&str>) -> MemoryCompletedTask { @@ -6510,6 +6911,7 @@ exit 2 flags: TestIssueFlags, ) -> ControlPlaneIssueSnapshot { ControlPlaneIssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-414".into(), title: "Test issue".into(), diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index cbbe1d914..297a9de2d 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -715,6 +715,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-255".to_owned(), title: "Observability and FrankenTUI".to_owned(), @@ -812,6 +813,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { issues: vec![ // Idle issue (eligible for execution) IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-300".to_owned(), title: "Idle task".to_owned(), @@ -862,6 +864,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // Completed issue with events and modified files IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-301".to_owned(), title: "Completed task".to_owned(), @@ -963,6 +966,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // Failed issue, first attempt (no retries exhausted) IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-302".to_owned(), title: "Failed task".to_owned(), @@ -1013,6 +1017,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // RetryQueued issue: queued but NOT eligible (not idle) IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-303".to_owned(), title: "Retry queued task".to_owned(), @@ -1063,6 +1068,7 @@ fn fixture_snapshot_rich(step: u64) -> DaemonSnapshot { }, // Blocked Idle issue: NOT eligible AND NOT queued IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-304".to_owned(), title: "Blocked idle task".to_owned(), @@ -1378,6 +1384,8 @@ async fn gateway_serves_capabilities_and_dashboard_snapshot() { .iter() .any(|harness| harness.kind == "acp" && harness.available + && harness.approvals.human_decision + && harness.actions.approve && harness.transport.modes == ["stdio"]) ); assert!(caps_response.harness_profiles.is_empty()); @@ -5447,6 +5455,291 @@ async fn gateway_serves_run_approvals_with_context() { server_task.abort(); } +#[tokio::test] +async fn acp_operator_routes_live_permission_question_and_plan_responses() { + use opensymphony::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteraction, OperatorInteractionKind, OperatorOption, + OperatorQuestion, + }; + let now = Utc::now(); + let make = |id: &str, kind, options, questions, plan| OperatorInteraction { + request_id: id.into(), + run_id: "run-worker-612".into(), + issue_id: "issue-612".into(), + issue_identifier: "COE-255".into(), + session_id: "session-612".into(), + generation: 3, + rpc_id: "0".into(), + kind, + title: id.into(), + options, + questions, + plan, + requested_at: now, + expires_at: now + chrono::Duration::minutes(2), + }; + let option = OperatorOption { + id: "opaque-allow".into(), + label: "Allow once".into(), + kind: "allow_once".into(), + }; + let permission = make( + "perm", + OperatorInteractionKind::Permission, + vec![option], + vec![], + None, + ); + let mut expired_permission = permission.clone(); + expired_permission.expires_at = now - chrono::Duration::seconds(1); + let question = make( + "question", + OperatorInteractionKind::Question, + vec![], + vec![OperatorQuestion { + id: "region".into(), + prompt: "Region?".into(), + options: vec![OperatorOption { + id: "west".into(), + label: "West".into(), + kind: "choice".into(), + }], + allow_multiple: false, + }], + None, + ); + let plan = make( + "plan", + OperatorInteractionKind::PlanApproval, + vec![], + vec![], + Some("Ship".into()), + ); + let mut snapshot = fixture_snapshot(0); + snapshot.issues[0].operator_interactions = + vec![permission.clone(), question.clone(), plan.clone()]; + let store = SnapshotStore::new(snapshot); + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let server = GatewayServer::new(store.clone()).with_operator_commands(tx); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("address"); + let server_task = tokio::spawn(async move { server.serve(listener).await.expect("serve") }); + let client = reqwest::Client::new(); + let approvals: serde_json::Value = client + .get(format!("http://{address}/api/v1/runs/COE-255/approvals")) + .send() + .await + .expect("approvals") + .json() + .await + .expect("json"); + assert_eq!( + approvals["approvals"].as_array().expect("approvals").len(), + 2 + ); + let inputs: serde_json::Value = client + .get(format!("http://{address}/api/v1/runs/COE-255/inputs")) + .send() + .await + .expect("inputs") + .json() + .await + .expect("json"); + assert_eq!(inputs["inputs"].as_array().expect("inputs").len(), 1); + + for (interaction, kind, answer_fields, expected) in [ + ( + permission, + ActionKind::ApprovalDecision, + serde_json::json!({"decision":"approved","option_id":"opaque-allow"}), + "permission", + ), + ( + question, + ActionKind::InputResponse, + serde_json::json!({"outcome":"answered","answers":[{"question_id":"region","selected_option_ids":["west"]}]}), + "question", + ), + ( + plan, + ActionKind::PlanDecision, + serde_json::json!({"decision":"approved"}), + "plan", + ), + ] { + let mut payload = serde_json::json!({"request_id":interaction.request_id,"run_id":interaction.run_id, + "issue_id":interaction.issue_id,"session_id":interaction.session_id,"generation":interaction.generation, + "rpc_id":interaction.rpc_id}); + payload + .as_object_mut() + .expect("object") + .extend(answer_fields.as_object().expect("fields").clone()); + let action = ActionDispatch { + schema_version: Default::default(), + correlation_id: format!("corr-{expected}"), + action_kind: kind, + target_entity: ActionTarget { + entity_kind: EntityKind::Run, + entity_id: "COE-255".into(), + }, + payload: Some(payload.clone()), + idempotency_key: None, + }; + for (field, value) in [ + ("run_id", serde_json::json!("wrong-run")), + ("session_id", serde_json::json!("wrong-session")), + ("generation", serde_json::json!(99)), + ("rpc_id", serde_json::json!("wrong-rpc")), + ] { + let mut wrong = action.clone(); + wrong.payload.as_mut().expect("payload")[field] = value; + let receipt: ActionReceipt = client + .post(format!("http://{address}/api/v1/actions/dispatch")) + .json(&wrong) + .send() + .await + .expect("wrong binding response") + .json() + .await + .expect("wrong binding receipt"); + assert_eq!(receipt.status, ActionStatus::Rejected, "{field}"); + assert!(rx.try_recv().is_err(), "wrong binding reached scheduler"); + } + if expected == "permission" { + let mut missing = action.clone(); + missing + .payload + .as_mut() + .expect("payload") + .as_object_mut() + .expect("object") + .remove("option_id"); + let receipt: ActionReceipt = client + .post(format!("http://{address}/api/v1/actions/dispatch")) + .json(&missing) + .send() + .await + .expect("missing option response") + .json() + .await + .expect("missing option receipt"); + assert_eq!(receipt.status, ActionStatus::Rejected); + assert!(rx.try_recv().is_err(), "missing option reached scheduler"); + let mut forged = action.clone(); + forged.payload.as_mut().expect("payload")["option_id"] = + serde_json::json!("unoffered-option"); + let receipt: ActionReceipt = client + .post(format!("http://{address}/api/v1/actions/dispatch")) + .json(&forged) + .send() + .await + .expect("invalid option response") + .json() + .await + .expect("invalid option receipt"); + assert_eq!(receipt.status, ActionStatus::Rejected); + assert!(rx.try_recv().is_err(), "unoffered option reached scheduler"); + } + let response = tokio::spawn({ + let client = client.clone(); + let url = format!("http://{address}/api/v1/actions/dispatch"); + let action = action.clone(); + async move { client.post(url).json(&action).send().await } + }); + let command = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv()) + .await + .expect("command timeout") + .expect("command"); + assert_eq!(command.interaction.request_id, interaction.request_id); + assert!(matches!( + (&command.answer, expected), + (OperatorAnswer::Permission { .. }, "permission") + | (OperatorAnswer::Question { .. }, "question") + | (OperatorAnswer::Plan { .. }, "plan") + )); + command.reply.send(Ok(())).expect("reply"); + let receipt: ActionReceipt = response + .await + .expect("request task") + .expect("response") + .json() + .await + .expect("receipt"); + assert_eq!(receipt.status, ActionStatus::Accepted); + + let duplicate = tokio::spawn({ + let client = client.clone(); + let url = format!("http://{address}/api/v1/actions/dispatch"); + let action = action.clone(); + async move { client.post(url).json(&action).send().await } + }); + let command = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv()) + .await + .expect("duplicate command timeout") + .expect("duplicate command"); + command + .reply + .send(Err("operator request is stale or already answered".into())) + .expect("duplicate rejection"); + let duplicate_receipt: ActionReceipt = duplicate + .await + .expect("duplicate task") + .expect("duplicate response") + .json() + .await + .expect("duplicate receipt"); + assert_eq!(duplicate_receipt.status, ActionStatus::Rejected); + + let mut stale = action.clone(); + stale.payload.as_mut().expect("payload")["generation"] = serde_json::json!(99); + let rejected: ActionReceipt = client + .post(format!("http://{address}/api/v1/actions/dispatch")) + .json(&stale) + .send() + .await + .expect("stale response") + .json() + .await + .expect("stale receipt"); + assert_eq!(rejected.status, ActionStatus::Rejected); + } + let mut expired_snapshot = fixture_snapshot(1); + expired_snapshot.issues[0].operator_interactions = vec![expired_permission.clone()]; + store.publish(expired_snapshot).await; + let expired_action = ActionDispatch { + schema_version: Default::default(), + correlation_id: "corr-expired".into(), + action_kind: ActionKind::ApprovalDecision, + target_entity: ActionTarget { + entity_kind: EntityKind::Run, + entity_id: "COE-255".into(), + }, + payload: Some(serde_json::json!({ + "request_id": expired_permission.request_id, + "run_id": expired_permission.run_id, + "issue_id": expired_permission.issue_id, + "session_id": expired_permission.session_id, + "generation": expired_permission.generation, + "rpc_id": expired_permission.rpc_id, + "decision": "approved", + "option_id": "opaque-allow" + })), + idempotency_key: None, + }; + let receipt: ActionReceipt = client + .post(format!("http://{address}/api/v1/actions/dispatch")) + .json(&expired_action) + .send() + .await + .expect("expired response") + .json() + .await + .expect("expired receipt"); + assert_eq!(receipt.status, ActionStatus::Rejected); + assert!(rx.try_recv().is_err(), "expired option reached scheduler"); + server_task.abort(); +} + #[tokio::test] async fn gateway_serves_run_events_with_data() { let store = SnapshotStore::new(fixture_snapshot_rich(0)); diff --git a/crates/opensymphony-orchestrator/src/lib.rs b/crates/opensymphony-orchestrator/src/lib.rs index fc98647d2..2d690e171 100644 --- a/crates/opensymphony-orchestrator/src/lib.rs +++ b/crates/opensymphony-orchestrator/src/lib.rs @@ -32,10 +32,10 @@ pub use parent_integration::{ ParentTransition, ParentVerificationAttempt, parent_command_identity, }; pub use scheduler::{ - HarnessRouteDecision, RecoveredRun, RecoveryRecord, RetryExhaustionRecord, RetryPendingRecord, - Scheduler, SchedulerConfig, SchedulerError, TrackerBackend, WorkerAbortReason, WorkerBackend, - WorkerInterruptAcknowledgement, WorkerLaunch, WorkerStartRequest, WorkerUpdate, - WorkspaceBackend, decide_issue_route, + HarnessRouteDecision, OperatorResponseDelivery, RecoveredRun, RecoveryRecord, + RetryExhaustionRecord, RetryPendingRecord, Scheduler, SchedulerConfig, SchedulerError, + TrackerBackend, WorkerAbortReason, WorkerBackend, WorkerInterruptAcknowledgement, WorkerLaunch, + WorkerStartRequest, WorkerUpdate, WorkspaceBackend, decide_issue_route, }; pub use selection::{ filter_issues_for_dispatch, issue_blocked_by_non_terminal_blockers, diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 14cf5be63..82e252182 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, future::Future, path::{Component, Path, PathBuf}, + pin::Pin, time::Duration, }; @@ -17,6 +18,9 @@ use crate::opensymphony_domain::{ TrackerIssueStateSnapshot, TrackerIssueSummary, TrackerStateId, WorkerId, WorkerOutcomeKind, WorkerOutcomeRecord, WorkspaceRecord, managed_repository_aliases, }; +use crate::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteraction, OperatorInteractionKind, +}; use crate::opensymphony_gateway_schema::capability::{HarnessCapability, HarnessKind}; use crate::opensymphony_workflow::{ResolvedWorkflow, RoutingConfig}; use crate::opensymphony_workspace::{ @@ -56,6 +60,54 @@ const PARENT_ELIGIBILITY_TIMEOUT: Duration = Duration::from_secs(30); // reserve one base timeout for each provider batch in the descendant subtree. const PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY: usize = 8; +fn validate_operator_answer( + request: &OperatorInteraction, + answer: &OperatorAnswer, +) -> Result<(), String> { + match (request.kind, answer) { + (OperatorInteractionKind::Permission, OperatorAnswer::Permission { option_id }) + if request.options.iter().any(|option| option.id == *option_id) => + { + Ok(()) + } + (OperatorInteractionKind::Question, OperatorAnswer::Question { answers }) => { + if answers.len() != request.questions.len() { + return Err("question answer count mismatch".into()); + } + for question in &request.questions { + let selected = answers + .iter() + .filter(|answer| answer.question_id == question.id) + .collect::>(); + if selected.len() != 1 + || selected[0].selected_option_ids.is_empty() + || (!question.allow_multiple && selected[0].selected_option_ids.len() != 1) + || selected[0] + .selected_option_ids + .iter() + .collect::>() + .len() + != selected[0].selected_option_ids.len() + || selected[0] + .selected_option_ids + .iter() + .any(|id| !question.options.iter().any(|option| option.id == *id)) + { + return Err("invalid structured question answer".into()); + } + } + Ok(()) + } + (OperatorInteractionKind::PlanApproval, OperatorAnswer::Plan { .. }) + | ( + OperatorInteractionKind::Question | OperatorInteractionKind::PlanApproval, + OperatorAnswer::Decline, + ) + | (_, OperatorAnswer::Cancel) => Ok(()), + _ => Err("answer kind or option does not match pending request".into()), + } +} + fn parent_eligibility_timeout(provider_work_units: usize) -> Duration { let batches = provider_work_units .max(1) @@ -289,6 +341,14 @@ pub struct WorkerLaunch { #[derive(Debug, Clone, PartialEq, Eq)] #[allow(clippy::large_enum_variant)] pub enum WorkerUpdate { + OperatorRequest { + worker_id: WorkerId, + interaction: OperatorInteraction, + }, + OperatorClosed { + worker_id: WorkerId, + request_id: String, + }, RuntimeEvent { worker_id: WorkerId, observed_at: TimestampMs, @@ -671,6 +731,30 @@ pub trait WorkerBackend { async fn poll_updates(&mut self) -> Result, Self::Error>; + async fn respond_operator_request( + &mut self, + _worker_id: &WorkerId, + _request_id: &str, + _answer: OperatorAnswer, + ) -> Result { + Ok(false) + } + + /// Enqueue the answer without waiting for a potentially slow harness + /// transport. The scheduler actor completes the decision after the owned + /// delivery receipt resolves. + async fn begin_operator_response( + &mut self, + worker_id: &WorkerId, + request_id: &str, + answer: OperatorAnswer, + ) -> Result { + let delivered = self + .respond_operator_request(worker_id, request_id, answer) + .await?; + Ok(OperatorResponseDelivery::new(async move { Ok(delivered) })) + } + async fn abort_worker( &mut self, worker_id: &WorkerId, @@ -692,6 +776,20 @@ pub trait WorkerBackend { } } +pub struct OperatorResponseDelivery( + Pin> + Send + 'static>>, +); + +impl OperatorResponseDelivery { + pub fn new(wait: impl Future> + Send + 'static) -> Self { + Self(Box::pin(wait)) + } + + pub async fn wait(self) -> Result { + self.0.await + } +} + #[derive(Debug, Error)] pub enum SchedulerError { #[error("invalid scheduler configuration: {detail}")] @@ -722,6 +820,8 @@ pub struct Scheduler { executions: BTreeMap, running_counts_by_state: HashMap, worker_metadata: HashMap, + pending_operator: HashMap, + responding_operator: HashSet, parent_issue_ids: HashSet, terminal_undispatched_parent_ids: HashSet, terminal_child_failure_ids: HashSet, @@ -770,6 +870,8 @@ where executions: BTreeMap::new(), running_counts_by_state: HashMap::new(), worker_metadata: HashMap::new(), + pending_operator: HashMap::new(), + responding_operator: HashSet::new(), parent_issue_ids: HashSet::new(), terminal_undispatched_parent_ids: HashSet::new(), terminal_child_failure_ids: HashSet::new(), @@ -2127,6 +2229,15 @@ where ); let mut snapshot = OrchestratorSnapshot::new(generated_at, daemon, issues); + snapshot.operator_interactions = self + .pending_operator + .values() + .filter(|(_, interaction)| interaction.expires_at > Utc::now()) + .map(|(_, interaction)| interaction.clone()) + .collect(); + snapshot + .operator_interactions + .sort_by(|a, b| a.request_id.cmp(&b.request_id)); snapshot.hierarchy = self .hierarchy_state .hierarchy @@ -2148,6 +2259,117 @@ where snapshot } + /// The scheduler owns the decision fence; the worker only delivers the + /// validated answer to its live ACP responder. + pub async fn respond_operator_request( + &mut self, + binding: &OperatorInteraction, + answer: OperatorAnswer, + ) -> Result<(), String> { + let delivery = self.begin_operator_response(binding, answer).await?; + self.complete_operator_response(binding, delivery.wait().await) + } + + /// Reserve the live decision in actor-owned state and enqueue it. Waiting + /// for the harness flush happens outside the scheduler actor. + pub async fn begin_operator_response( + &mut self, + binding: &OperatorInteraction, + answer: OperatorAnswer, + ) -> Result { + let (worker_id, current) = self + .pending_operator + .get(&binding.request_id) + .ok_or("operator request is stale or already answered")?; + if current != binding + || self.responding_operator.contains(&binding.request_id) + || current.expires_at <= Utc::now() + || self + .worker_metadata + .get(worker_id) + .is_none_or(|meta| meta.issue_id.as_str() != current.issue_id) + || self + .executions + .get(&IssueId::new(current.issue_id.clone()).map_err(|e| e.to_string())?) + .and_then(IssueExecution::current_run) + .is_none_or(|run| run.worker_id != *worker_id) + { + return Err("operator request binding is stale or expired".into()); + } + validate_operator_answer(current, &answer)?; + let worker_id = worker_id.clone(); + self.responding_operator.insert(binding.request_id.clone()); + match self + .worker + .begin_operator_response(&worker_id, &binding.request_id, answer) + .await + { + Ok(delivery) => Ok(delivery), + Err(error) => { + self.responding_operator.remove(&binding.request_id); + Err(format!("ACP response delivery failed: {error}")) + } + } + } + + /// Apply a completed delivery only on the orchestrator actor. A retryable + /// failure leaves the still-live interaction pending for another answer. + pub fn complete_operator_response( + &mut self, + binding: &OperatorInteraction, + delivered: Result, + ) -> Result<(), String> { + if !self.responding_operator.remove(&binding.request_id) { + return Err("operator response attempt is stale".into()); + } + let current = self.pending_operator.get(&binding.request_id).cloned(); + match delivered { + Ok(true) => { + if let Some((worker_id, interaction)) = current + && interaction == *binding + { + self.pending_operator.remove(&binding.request_id); + self.observe_operator_resolution(&worker_id, binding); + } + Ok(()) + } + Ok(false) => { + if let Some((worker_id, interaction)) = current + && interaction == *binding + { + self.pending_operator.remove(&binding.request_id); + self.observe_operator_resolution(&worker_id, binding); + } + Err("ACP responder is no longer live".into()) + } + Err(error) => Err(format!("ACP response delivery failed: {error}")), + } + } + + fn observe_operator_resolution( + &mut self, + worker_id: &WorkerId, + interaction: &OperatorInteraction, + ) { + let Ok(issue_id) = IssueId::new(interaction.issue_id.clone()) else { + return; + }; + if self + .worker_metadata + .get(worker_id) + .is_none_or(|metadata| metadata.issue_id != issue_id) + { + return; + } + if let Some(execution) = self.executions.get_mut(&issue_id) + && execution + .current_run() + .is_some_and(|run| run.worker_id == *worker_id) + { + execution.observe_operator_resolution(datetime_to_timestamp(Utc::now())); + } + } + pub async fn bootstrap( &mut self, observed_at: TimestampMs, @@ -2167,6 +2389,22 @@ where Ok(self.snapshot(observed_at)) } + /// Apply queued worker reports without waiting for a tracker polling cycle. + pub async fn drain_worker_updates( + &mut self, + observed_at: TimestampMs, + ) -> Result { + let updates = self + .worker + .poll_updates() + .await + .map_err(|error| SchedulerError::Worker { + detail: error.to_string(), + })?; + self.apply_worker_updates(updates).await?; + Ok(self.snapshot(observed_at)) + } + pub async fn tick( &mut self, observed_at: TimestampMs, @@ -2245,14 +2483,7 @@ where self.flush_pending_finished_updates().await?; } - let updates = self - .worker - .poll_updates() - .await - .map_err(|error| SchedulerError::Worker { - detail: error.to_string(), - })?; - self.apply_worker_updates(updates).await?; + self.drain_worker_updates(observed_at).await?; if !self.linear_cooldown_active(observed_at) { let freshly_inactive_issue_ids = pre_update_full_snapshot .as_ref() @@ -6712,6 +6943,66 @@ where let mut first_error = None; for update in updates { match update { + WorkerUpdate::OperatorRequest { + worker_id, + interaction, + } => { + if let Some(meta) = self.worker_metadata.get(&worker_id) + && meta.harness_kind.as_deref() == Some("acp") + && interaction.issue_id == meta.issue_id.as_str() + && interaction.run_id == format!("run-{worker_id}") + && interaction.generation > 0 + && interaction.expires_at > Utc::now() + && !self.pending_operator.contains_key(&interaction.request_id) + && self + .executions + .get(&meta.issue_id) + .is_some_and(|execution| { + execution.status() == SchedulerStatus::Running + && execution + .current_run() + .is_some_and(|run| run.worker_id == worker_id) + }) + { + let (reason, summary) = match interaction.kind { + OperatorInteractionKind::Permission => { + ("permission_request", "ACP agent requested permission") + } + OperatorInteractionKind::Question => { + ("form_request", "ACP agent requested form input") + } + OperatorInteractionKind::PlanApproval => { + ("plan_request", "ACP agent requested plan approval") + } + }; + if let Some(execution) = self.executions.get_mut(&meta.issue_id) { + execution.observe_runtime_event( + datetime_to_timestamp(Utc::now()), + Some(format!("acp-operator-{}", interaction.request_id)), + Some("acp.waiting_for_input".into()), + Some(summary.into()), + Some(serde_json::json!({ + "reason": reason, + "operator_responses": true, + })), + )?; + } + self.pending_operator + .insert(interaction.request_id.clone(), (worker_id, interaction)); + } + } + WorkerUpdate::OperatorClosed { + worker_id, + request_id, + } => { + if let Some((owner, interaction)) = self.pending_operator.get(&request_id) + && *owner == worker_id + { + let interaction = interaction.clone(); + self.pending_operator.remove(&request_id); + self.observe_operator_resolution(&worker_id, &interaction); + } + } WorkerUpdate::RuntimeEvent { worker_id, observed_at, @@ -6774,6 +7065,8 @@ where } } WorkerUpdate::Finished { worker_id, outcome } => { + self.pending_operator + .retain(|_, (owner, _)| *owner != worker_id); let Some(metadata) = self.worker_metadata.remove(&worker_id) else { continue; }; @@ -6959,9 +7252,15 @@ where .get(issue_id) .and_then(ParentIntegrationController::current_attempt_deadline) .is_some_and(|deadline| deadline <= observed_at); + let awaiting_input = self.pending_operator.values().any(|(worker, request)| { + request.issue_id == issue_id.as_str() + && request.expires_at > Utc::now() + && self.worker_metadata.contains_key(worker) + }); match execution.state() { crate::opensymphony_domain::SchedulerState::Running { stall, .. } - if stall.stalled_at <= observed_at || absolute_timeout => + if (stall.stalled_at <= observed_at && !awaiting_input) + || absolute_timeout => { Some((issue_id.clone(), absolute_timeout)) } diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index fa0a38327..db0144a4b 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -10,15 +10,18 @@ use crate::opensymphony_domain::{ RepositoryBindingOutcome, RepositoryIdentity, RepositoryInventoryEntry, RepositoryRouting, RepositoryRoutingMode, SafeRemoteFingerprint, TrackerErrorCategory, TrackerIssueRef, }; +use crate::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteraction, OperatorInteractionKind, +}; use crate::opensymphony_orchestrator::{ ChildEligibilityEvidence, ConversationId, ConversationMetadata, HierarchySnapshot, IssueId, IssueIdentifier, IssueRef, IssueState, IssueStateCategory, LeaseKind, LeaseOwner, LeaseRecord, - LeaseResource, NormalizedIssue, ParentEligibilityEvidence, ParentRepairPolicy, RecoveredRun, - RecoveryRecord, ReleaseReason, RequiredMergeCommit, RetryAttempt, RetryEntry, - RetryExhaustionRecord, RetryPendingRecord, RetryReason, RuntimeStreamState, Scheduler, - SchedulerConfig, SchedulerStatus, TimestampMs, TrackerBackend, TrackerIssue, - TrackerIssueBlocker, TrackerIssueState, TrackerIssueStateKind, TrackerIssueStateSnapshot, - TrackerIssueSummary, WorkerAbortReason, WorkerBackend, WorkerId, + LeaseResource, NormalizedIssue, OperatorResponseDelivery, ParentEligibilityEvidence, + ParentRepairPolicy, RecoveredRun, RecoveryRecord, ReleaseReason, RequiredMergeCommit, + RetryAttempt, RetryEntry, RetryExhaustionRecord, RetryPendingRecord, RetryReason, + RuntimeStreamState, Scheduler, SchedulerConfig, SchedulerStatus, TimestampMs, TrackerBackend, + TrackerIssue, TrackerIssueBlocker, TrackerIssueState, TrackerIssueStateKind, + TrackerIssueStateSnapshot, TrackerIssueSummary, WorkerAbortReason, WorkerBackend, WorkerId, WorkerInterruptAcknowledgement, WorkerLaunch, WorkerOutcomeKind, WorkerOutcomeRecord, WorkerStartRequest, WorkerUpdate, WorkspaceBackend, WorkspaceKey, WorkspaceRecord, decide_issue_route, @@ -932,6 +935,9 @@ struct FakeWorker { interrupts: Vec, interrupt_results: VecDeque>, launch_results: VecDeque>, + operator_responses: Vec<(WorkerId, String)>, + operator_response_results: VecDeque>, + delayed_operator_receipt: Option>>, } impl WorkerBackend for FakeWorker { @@ -963,6 +969,37 @@ impl WorkerBackend for FakeWorker { Ok(self.updates.drain(..).collect()) } + async fn respond_operator_request( + &mut self, + worker_id: &WorkerId, + request_id: &str, + _answer: OperatorAnswer, + ) -> Result { + self.operator_responses + .push((worker_id.clone(), request_id.to_owned())); + self.operator_response_results + .pop_front() + .unwrap_or(Ok(true)) + } + + async fn begin_operator_response( + &mut self, + worker_id: &WorkerId, + request_id: &str, + answer: OperatorAnswer, + ) -> Result { + let delivered = self + .respond_operator_request(worker_id, request_id, answer) + .await?; + if let Some(receipt) = self.delayed_operator_receipt.take() { + Ok(OperatorResponseDelivery::new(async move { + receipt.await.unwrap_or(Ok(false)) + })) + } else { + Ok(OperatorResponseDelivery::new(async move { Ok(delivered) })) + } + } + async fn abort_worker( &mut self, worker_id: &WorkerId, @@ -987,6 +1024,230 @@ impl WorkerBackend for FakeWorker { } } +#[tokio::test] +async fn delayed_operator_answer_and_closure_restart_stall_clock() { + for answer_by_operator in [true, false] { + let now = Utc::now(); + let start = ts((now.timestamp_millis() - 1_000) as u64); + let issue_id = IssueId::new("operator-stall").expect("issue id"); + let tracker = FakeTracker { + active: vec![tracker_issue( + issue_id.as_str(), + "COE-612", + "In Progress", + 0, + )], + ..Default::default() + }; + let mut config = scheduler_config(); + config.routing.harness = "acp".into(); + config.stall_timeout_ms = Some(500); + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace::default(), + FakeWorker::default(), + config, + ); + scheduler.tick(start).await.expect("dispatch ACP worker"); + let worker_id = scheduler.worker().launches[0].run.worker_id.clone(); + let interaction = OperatorInteraction { + request_id: format!("request-{answer_by_operator}"), + run_id: format!("run-{worker_id}"), + issue_id: issue_id.to_string(), + issue_identifier: "COE-612".into(), + session_id: "session".into(), + generation: 1, + rpc_id: "1".into(), + kind: OperatorInteractionKind::Permission, + title: "Permission".into(), + options: Vec::new(), + questions: Vec::new(), + plan: None, + requested_at: now, + expires_at: now + chrono::Duration::minutes(1), + }; + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::OperatorRequest { + worker_id: worker_id.clone(), + interaction: interaction.clone(), + }); + let opened = scheduler + .drain_worker_updates(ts(now.timestamp_millis() as u64)) + .await + .expect("accept operator request"); + assert_eq!(opened.operator_interactions.len(), 1); + let old_deadline = opened.issues[0] + .runtime + .stalled_at + .expect("running stall deadline"); + tokio::time::sleep(Duration::from_millis(550)).await; + assert!(old_deadline < ts(Utc::now().timestamp_millis() as u64)); + + if answer_by_operator { + scheduler + .worker_mut() + .operator_response_results + .push_back(Err(FakeError { + message: "queued ACP answer expired before consumption".into(), + category: None, + retry_after: None, + })); + assert!( + scheduler + .respond_operator_request(&interaction, OperatorAnswer::Cancel) + .await + .is_err() + ); + assert_eq!( + scheduler + .snapshot(ts(Utc::now().timestamp_millis() as u64)) + .operator_interactions + .len(), + 1, + "retryable worker timeout retains the live callback" + ); + scheduler + .respond_operator_request(&interaction, OperatorAnswer::Cancel) + .await + .expect("deliver operator answer"); + assert_eq!(scheduler.worker().operator_responses.len(), 2); + } else { + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::OperatorClosed { + worker_id, + request_id: interaction.request_id, + }); + scheduler + .drain_worker_updates(ts(Utc::now().timestamp_millis() as u64)) + .await + .expect("close operator request"); + } + let resumed = scheduler.snapshot(ts(Utc::now().timestamp_millis() as u64)); + assert!(resumed.operator_interactions.is_empty()); + assert!( + resumed.issues[0] + .runtime + .stalled_at + .expect("resumed stall deadline") + > ts(Utc::now().timestamp_millis() as u64) + ); + scheduler + .tick(ts(Utc::now().timestamp_millis() as u64)) + .await + .expect("resumed scheduler tick"); + assert_eq!( + scheduler + .execution(&issue_id) + .expect("active execution") + .status(), + SchedulerStatus::Running + ); + assert!(scheduler.worker().aborted.is_empty()); + } +} + +#[tokio::test] +async fn pending_operator_flush_does_not_block_scheduler_or_duplicate_response() { + let now = Utc::now(); + let issue_id = IssueId::new("operator-async").expect("issue id"); + let tracker = FakeTracker { + active: vec![tracker_issue( + issue_id.as_str(), + "COE-612", + "In Progress", + 0, + )], + ..Default::default() + }; + let mut config = scheduler_config(); + config.routing.harness = "acp".into(); + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace::default(), + FakeWorker::default(), + config, + ); + let observed_at = ts(now.timestamp_millis() as u64); + scheduler + .tick(observed_at) + .await + .expect("dispatch ACP worker"); + let worker_id = scheduler.worker().launches[0].run.worker_id.clone(); + let interaction = OperatorInteraction { + request_id: "delayed-flush".into(), + run_id: format!("run-{worker_id}"), + issue_id: issue_id.to_string(), + issue_identifier: "COE-612".into(), + session_id: "session".into(), + generation: 1, + rpc_id: "opaque-token".into(), + kind: OperatorInteractionKind::Permission, + title: "Permission".into(), + options: Vec::new(), + questions: Vec::new(), + plan: None, + requested_at: now, + expires_at: now + chrono::Duration::minutes(1), + }; + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::OperatorRequest { + worker_id: worker_id.clone(), + interaction: interaction.clone(), + }); + scheduler + .drain_worker_updates(observed_at) + .await + .expect("open request"); + let (acknowledge, receipt) = tokio::sync::oneshot::channel(); + scheduler.worker_mut().delayed_operator_receipt = Some(receipt); + let delivery = scheduler + .begin_operator_response(&interaction, OperatorAnswer::Cancel) + .await + .expect("enqueue response without awaiting flush"); + assert!( + scheduler + .begin_operator_response(&interaction, OperatorAnswer::Cancel) + .await + .is_err(), + "in-flight answer excludes duplicates" + ); + tokio::time::timeout(Duration::from_millis(250), scheduler.tick(observed_at)) + .await + .expect("tracker tick remains responsive during ACP flush") + .expect("tracker tick"); + assert_eq!( + scheduler.snapshot(observed_at).operator_interactions.len(), + 1 + ); + scheduler + .worker_mut() + .updates + .push_back(WorkerUpdate::OperatorClosed { + worker_id, + request_id: interaction.request_id.clone(), + }); + scheduler + .drain_worker_updates(observed_at) + .await + .expect("worker closure"); + acknowledge.send(Ok(true)).expect("flush acknowledgement"); + scheduler + .complete_operator_response(&interaction, delivery.wait().await) + .expect("delivered response remains accepted after worker closure"); + assert!( + scheduler + .snapshot(observed_at) + .operator_interactions + .is_empty() + ); +} + #[tokio::test] async fn parent_provider_cooldown_stops_later_lookups_in_the_same_dispatch_pass() { let mut parent_a = tracker_issue("parent-a", "COE-A", "In Progress", 1); diff --git a/crates/opensymphony-tui/src/lib.rs b/crates/opensymphony-tui/src/lib.rs index b687f504c..e31415c55 100644 --- a/crates/opensymphony-tui/src/lib.rs +++ b/crates/opensymphony-tui/src/lib.rs @@ -15,6 +15,12 @@ use crate::opensymphony_control::{ControlPlaneClient, ControlPlaneClientError}; use crate::opensymphony_domain::{ ControlPlaneIssueRuntimeState, ControlPlaneIssueSnapshot as IssueSnapshot, SnapshotEnvelope, }; +use crate::opensymphony_gateway_schema::{ + action::{ActionDispatch, ActionKind, ActionStatus, ActionTarget}, + approval::{OperatorInteraction, OperatorInteractionKind}, + envelope::EntityKind, + version::SchemaVersion, +}; use chrono::{DateTime, Utc}; use crossterm::terminal; use ftui::{ @@ -77,6 +83,10 @@ pub struct TuiState { pub selected_issue: usize, pub latest_snapshot: Option, pub status_line: String, + operator_request_index: usize, + operator_question_index: usize, + operator_option_page: usize, + operator_answers: HashMap>>, workspace_status: HashMap, selected_changed_file: usize, detail_diff_open: bool, @@ -94,6 +104,10 @@ impl Default for TuiState { selected_issue: 0, latest_snapshot: None, status_line: "connecting to control plane".to_owned(), + operator_request_index: 0, + operator_question_index: 0, + operator_option_page: 0, + operator_answers: HashMap::new(), workspace_status: HashMap::new(), selected_changed_file: 0, detail_diff_open: false, @@ -193,6 +207,15 @@ impl TuiState { TuiAction::SnapshotReceived(envelope) => { let selected_issue_identifier = self.selected_issue().map(|issue| issue.identifier.clone()); + let visible_requests = envelope + .snapshot + .issues + .iter() + .flat_map(|issue| &issue.operator_interactions) + .map(|request| request.request_id.as_str()) + .collect::>(); + self.operator_answers + .retain(|request_id, _| visible_requests.contains(request_id.as_str())); self.latest_snapshot = Some(*envelope); if !matches!(self.connection, ConnectionState::Live) { self.status_line = match self.connection { @@ -692,6 +715,10 @@ impl TuiState { match self.selected_issue() { Some(issue) => { + if lines.len() < max_rows { + let operator_budget = max_rows.saturating_sub(lines.len()).min(8); + lines.extend(self.operator_lines(width, operator_budget)); + } let id_style = Style::new().fg(CYAN).bold(); let deps = dependency_summary( self.latest_snapshot @@ -1554,6 +1581,140 @@ impl TuiState { .and_then(|snapshot| snapshot.snapshot.issues.get(self.selected_issue)) } + fn selected_operator(&self) -> Option<&OperatorInteraction> { + let issue = self.selected_issue()?; + if issue.operator_interactions.is_empty() { + return None; + } + issue + .operator_interactions + .get(self.operator_request_index % issue.operator_interactions.len()) + } + + fn operator_lines(&self, width: usize, budget: usize) -> Vec { + let Some(issue) = self.selected_issue() else { + return Vec::new(); + }; + let Some(request) = self.selected_operator() else { + return Vec::new(); + }; + let mut rows = vec![Line::from(Span::styled( + fit( + &format!( + "ACP {}/{}: {} [o next; x cancel]", + self.operator_request_index % issue.operator_interactions.len() + 1, + issue.operator_interactions.len(), + request.title + ), + width, + ), + Style::new().fg(YELLOW).bold(), + ))]; + const OPTIONS_PER_PAGE: usize = 4; + match request.kind { + OperatorInteractionKind::Permission => { + let page = self + .operator_option_page + .min(request.options.len().saturating_sub(1) / OPTIONS_PER_PAGE); + let pages = request.options.len().div_ceil(OPTIONS_PER_PAGE); + rows.push(Line::from(fit( + &format!( + "Options {}/{} [, previous; . next; 1-4 select]", + page + 1, + pages + ), + width, + ))); + rows.extend( + request + .options + .iter() + .skip(page * OPTIONS_PER_PAGE) + .take(OPTIONS_PER_PAGE) + .enumerate() + .map(|(index, option)| { + Line::from(fit( + &format!("{} {} ({})", index + 1, option.label, option.kind), + width, + )) + }), + ); + } + OperatorInteractionKind::Question => { + if let Some(question) = request + .questions + .get(self.operator_question_index % request.questions.len().max(1)) + { + rows.push(Line::from(fit( + &format!( + "Question {}/{}: {} [[/] next; s submit; n decline]", + self.operator_question_index % request.questions.len() + 1, + request.questions.len(), + question.prompt + ), + width, + ))); + let selected = + self.operator_answers + .get(&request.request_id) + .and_then(|answers| { + answers.get(self.operator_question_index % request.questions.len()) + }); + let page = self + .operator_option_page + .min(question.options.len().saturating_sub(1) / OPTIONS_PER_PAGE); + let pages = question.options.len().div_ceil(OPTIONS_PER_PAGE); + rows.push(Line::from(fit( + &format!( + "Options {}/{} [, previous; . next; 1-4 select]", + page + 1, + pages + ), + width, + ))); + rows.extend( + question + .options + .iter() + .skip(page * OPTIONS_PER_PAGE) + .take(OPTIONS_PER_PAGE) + .enumerate() + .map(|(index, option)| { + Line::from(fit( + &format!( + "{} [{}] {}", + index + 1, + if selected + .is_some_and(|selected| selected.contains(&option.id)) + { + "x" + } else { + " " + }, + option.label + ), + width, + )) + }), + ); + } + } + OperatorInteractionKind::PlanApproval => { + rows.push(Line::from(fit("[y approve; n reject]", width))); + rows.extend( + request + .plan + .as_deref() + .unwrap_or_default() + .lines() + .map(|line| Line::from(fit(line, width))), + ); + } + } + rows.truncate(budget); + rows + } + fn visible_issue_rows<'a>( &self, snapshot: &'a SnapshotEnvelope, @@ -2162,6 +2323,16 @@ enum AppMessage { FocusPrevious, ToggleDetailDiff, ToggleTimelineMode, + NextOperator, + NextQuestion, + PreviousQuestion, + NextOptionPage, + PreviousOptionPage, + SelectOperatorOption(usize), + SubmitOperator, + ApproveOperator, + RejectOperator, + CancelOperator, Quit, } @@ -2176,6 +2347,18 @@ impl From for AppMessage { KeyCode::BackTab => AppMessage::FocusPrevious, KeyCode::Enter => AppMessage::ToggleDetailDiff, KeyCode::Char('e') => AppMessage::ToggleTimelineMode, + KeyCode::Char('o') => AppMessage::NextOperator, + KeyCode::Char(']') => AppMessage::NextQuestion, + KeyCode::Char('[') => AppMessage::PreviousQuestion, + KeyCode::Char('.') => AppMessage::NextOptionPage, + KeyCode::Char(',') => AppMessage::PreviousOptionPage, + KeyCode::Char(digit @ '1'..='4') => { + AppMessage::SelectOperatorOption(digit as usize - '1' as usize) + } + KeyCode::Char('s') => AppMessage::SubmitOperator, + KeyCode::Char('y') => AppMessage::ApproveOperator, + KeyCode::Char('n') => AppMessage::RejectOperator, + KeyCode::Char('x') => AppMessage::CancelOperator, _ => AppMessage::Tick, }, _ => AppMessage::Tick, @@ -2184,16 +2367,17 @@ impl From for AppMessage { } pub fn run_operator(base_url: Url, exit_after: Option) -> Result<(), TuiError> { - let bridge = BridgeHandle::spawn(base_url); + let bridge = BridgeHandle::spawn(base_url.clone()); let workspace_status = WorkspaceStatusHandle::spawn(); let outcome = Arc::new(Mutex::new(RunOutcome::default())); - let app = OperatorApp::new( + let mut app = OperatorApp::new( bridge.mailbox(), workspace_status.mailbox(), workspace_status.request_sender(), exit_after, Arc::clone(&outcome), ); + app.operator_url = Some(base_url); let config = tui_program_config(); let run_result = ftui::Program::with_config(app, config) .and_then(|mut program| program.run()) @@ -2247,6 +2431,11 @@ struct OperatorApp { started_at: Instant, saw_live_stream: bool, outcome: Arc>, + operator_url: Option, + operator_result_tx: mpsc::Sender, + operator_result_rx: mpsc::Receiver, + operator_response_pending: bool, + operator_response_request_id: Option, } impl OperatorApp { @@ -2257,6 +2446,7 @@ impl OperatorApp { exit_after: Option, outcome: Arc>, ) -> Self { + let (operator_result_tx, operator_result_rx) = mpsc::channel(); Self { state: TuiState::default(), bridge, @@ -2268,9 +2458,206 @@ impl OperatorApp { started_at: Instant::now(), saw_live_stream: false, outcome, + operator_url: None, + operator_result_tx, + operator_result_rx, + operator_response_pending: false, + operator_response_request_id: None, + } + } + + fn choose_operator_option(&mut self, index: usize) { + const OPTIONS_PER_PAGE: usize = 4; + if index >= OPTIONS_PER_PAGE { + return; + } + let Some(request) = self.state.selected_operator().cloned() else { + return; + }; + let option_count = match request.kind { + OperatorInteractionKind::Permission => request.options.len(), + OperatorInteractionKind::Question => request + .questions + .get(self.state.operator_question_index % request.questions.len().max(1)) + .map_or(0, |question| question.options.len()), + OperatorInteractionKind::PlanApproval => 0, + }; + let page = self + .state + .operator_option_page + .min(option_count.saturating_sub(1) / OPTIONS_PER_PAGE); + let index = page * OPTIONS_PER_PAGE + index; + match request.kind { + OperatorInteractionKind::Permission => { + let Some(option) = request.options.get(index) else { + return; + }; + let decision = if matches!(option.kind.as_str(), "allow_once" | "allow_always") { + "approved" + } else if matches!(option.kind.as_str(), "reject_once" | "reject_always") { + "rejected" + } else { + return; + }; + let option_id = option.id.clone(); + self.send_operator_response( + request, + ActionKind::ApprovalDecision, + serde_json::json!({"decision": decision, "option_id": option_id}), + ); + } + OperatorInteractionKind::Question => { + let question_index = + self.state.operator_question_index % request.questions.len().max(1); + let Some(question) = request.questions.get(question_index) else { + return; + }; + let Some(option) = question.options.get(index) else { + return; + }; + let answers = self + .state + .operator_answers + .entry(request.request_id) + .or_insert_with(|| vec![Vec::new(); request.questions.len()]); + if question.allow_multiple { + if let Some(position) = answers[question_index] + .iter() + .position(|id| id == &option.id) + { + answers[question_index].remove(position); + } else { + answers[question_index].push(option.id.clone()); + } + } else { + answers[question_index] = vec![option.id.clone()]; + } + } + OperatorInteractionKind::PlanApproval => {} + } + } + + fn operator_decision(&mut self, choice: char) { + let Some(request) = self.state.selected_operator().cloned() else { + return; + }; + match request.kind { + OperatorInteractionKind::Permission if choice == 'x' => self.send_operator_response( + request, + ActionKind::ApprovalDecision, + serde_json::json!({"decision": "cancelled"}), + ), + OperatorInteractionKind::PlanApproval if matches!(choice, 'y' | 'n' | 'x') => self + .send_operator_response( + request, + ActionKind::PlanDecision, + serde_json::json!({"decision": match choice { + 'y' => "approved", 'n' => "rejected", _ => "cancelled" }}), + ), + OperatorInteractionKind::Question if matches!(choice, 's' | 'n' | 'x') => { + let mut outcome = match choice { + 's' => "answered", + 'n' => "declined", + _ => "cancelled", + }; + let answers = self + .state + .operator_answers + .get(&request.request_id) + .cloned() + .unwrap_or_default(); + if outcome == "answered" + && (answers.len() != request.questions.len() + || answers.iter().any(Vec::is_empty)) + { + self.state.status_line = + "Select at least one option for each ACP question".into(); + return; + } + if outcome != "answered" { + outcome = if choice == 'n' { + "declined" + } else { + "cancelled" + }; + } + let answers = request.questions.iter().enumerate().map(|(index, question)| serde_json::json!({ + "question_id": question.id, "selected_option_ids": answers.get(index).cloned().unwrap_or_default() + })).collect::>(); + self.send_operator_response( + request, + ActionKind::InputResponse, + serde_json::json!({"outcome": outcome, "answers": answers}), + ); + } + _ => {} } } + fn send_operator_response( + &mut self, + request: OperatorInteraction, + kind: ActionKind, + response: serde_json::Value, + ) { + if self.operator_response_pending { + return; + } + let Some(base_url) = self.operator_url.clone() else { + return; + }; + let Some(payload) = response.as_object() else { + return; + }; + let mut payload = payload.clone(); + payload.extend(serde_json::json!({"request_id": request.request_id, "run_id": request.run_id, + "issue_id": request.issue_id, "session_id": request.session_id, "generation": request.generation, + "rpc_id": request.rpc_id}).as_object().cloned().unwrap_or_default()); + let action = ActionDispatch { + schema_version: SchemaVersion::v1(), + correlation_id: format!("operator-{}-{}", request.request_id, uuid::Uuid::new_v4()), + action_kind: kind, + target_entity: ActionTarget { + entity_kind: EntityKind::Run, + entity_id: request.issue_identifier, + }, + payload: Some(serde_json::Value::Object(payload)), + idempotency_key: None, + }; + self.operator_response_pending = true; + self.operator_response_request_id = Some(request.request_id.clone()); + self.state.status_line = "Sending ACP operator response".into(); + let tx = self.operator_result_tx.clone(); + thread::spawn(move || { + let result = (|| -> Result { + let url = base_url + .join("/api/v1/actions/dispatch") + .map_err(|e| e.to_string())?; + let receipt = reqwest::blocking::Client::builder() + // A claimed gateway command waits for the authoritative + // ACP input-sink flush. A total HTTP timeout could report + // failure while that answer is still being delivered. + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())? + .post(url) + .json(&action) + .send() + .map_err(|e| e.to_string())? + .json::() + .map_err(|e| e.to_string())?; + if receipt.status == ActionStatus::Accepted { + Ok("ACP operator response accepted".into()) + } else { + Err(receipt + .reason + .unwrap_or_else(|| "ACP operator response rejected".into())) + } + })(); + let _ = tx.send(result.unwrap_or_else(|error| format!("ACP response failed: {error}"))); + }); + } + fn drain_bridge(&mut self) { let mut bridge = self .bridge @@ -2943,6 +3330,15 @@ impl Model for OperatorApp { fn update(&mut self, message: Self::Message) -> Cmd { self.drain_bridge(); self.drain_workspace_status(); + while let Ok(result) = self.operator_result_rx.try_recv() { + self.operator_response_pending = false; + if let Some(request_id) = self.operator_response_request_id.take() + && result == "ACP operator response accepted" + { + self.state.operator_answers.remove(&request_id); + } + self.state.status_line = result; + } match message { AppMessage::Tick => {} AppMessage::MoveSelectionUp => self.state.reduce(TuiAction::MoveSelectionUp), @@ -2951,6 +3347,33 @@ impl Model for OperatorApp { AppMessage::FocusPrevious => self.state.reduce(TuiAction::FocusPrevious), AppMessage::ToggleDetailDiff => self.state.reduce(TuiAction::ToggleDetailDiff), AppMessage::ToggleTimelineMode => self.state.reduce(TuiAction::ToggleTimelineMode), + AppMessage::NextOperator => { + self.state.operator_request_index = + self.state.operator_request_index.saturating_add(1); + self.state.operator_question_index = 0; + self.state.operator_option_page = 0; + } + AppMessage::NextQuestion => { + self.state.operator_question_index = + self.state.operator_question_index.saturating_add(1); + self.state.operator_option_page = 0; + } + AppMessage::PreviousQuestion => { + self.state.operator_question_index = + self.state.operator_question_index.saturating_sub(1); + self.state.operator_option_page = 0; + } + AppMessage::NextOptionPage => { + self.state.operator_option_page = self.state.operator_option_page.saturating_add(1) + } + AppMessage::PreviousOptionPage => { + self.state.operator_option_page = self.state.operator_option_page.saturating_sub(1) + } + AppMessage::SelectOperatorOption(index) => self.choose_operator_option(index), + AppMessage::SubmitOperator => self.operator_decision('s'), + AppMessage::ApproveOperator => self.operator_decision('y'), + AppMessage::RejectOperator => self.operator_decision('n'), + AppMessage::CancelOperator => self.operator_decision('x'), AppMessage::Quit => return Cmd::quit(), } self.request_selected_workspace_status(); @@ -4395,6 +4818,7 @@ mod tests { }, issues: (0..issue_count) .map(|index| IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: format!("COE-{}", 255 + index), title: format!("Issue {index}"), @@ -4674,6 +5098,164 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread")] + async fn terminal_operator_keys_send_permission_and_question_to_gateway() { + use crate::opensymphony_control::SnapshotStore; + use crate::opensymphony_gateway::{GatewayServer, OperatorCommand}; + use crate::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteraction, OperatorInteractionKind, OperatorOption, + OperatorQuestion, + }; + + let now = Utc::now(); + let make = |request_id: &str, kind, options, questions| OperatorInteraction { + request_id: request_id.into(), + run_id: "run-tui".into(), + issue_id: "issue-tui".into(), + issue_identifier: "COE-255".into(), + session_id: "session-tui".into(), + generation: 7, + rpc_id: "0".into(), + kind, + title: request_id.into(), + options, + questions, + plan: None, + requested_at: now, + expires_at: now + chrono::Duration::minutes(2), + }; + let permission = make( + "permission", + OperatorInteractionKind::Permission, + (0..13) + .map(|index| OperatorOption { + id: format!("opaque-allow-{index}"), + label: format!("Allow option {index}"), + kind: "allow_once".into(), + }) + .collect(), + Vec::new(), + ); + let question = make( + "question", + OperatorInteractionKind::Question, + Vec::new(), + vec![OperatorQuestion { + id: "region".into(), + prompt: "Region?".into(), + options: (0..13) + .map(|index| OperatorOption { + id: format!("west-{index}"), + label: format!("Region {index}"), + kind: "choice".into(), + }) + .collect(), + allow_multiple: false, + }], + ); + let mut snapshot = fixture(1, 1); + snapshot.snapshot.issues[0].operator_interactions = vec![permission, question]; + let store = SnapshotStore::new(snapshot.snapshot.clone()); + let (commands_tx, mut commands_rx) = tokio::sync::mpsc::channel::(4); + let gateway = GatewayServer::new(store).with_operator_commands(commands_tx); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("gateway bind"); + let base_url = Url::parse(&format!( + "http://{}/", + listener.local_addr().expect("gateway address") + )) + .expect("gateway URL"); + let server = tokio::spawn(async move { gateway.serve(listener).await.expect("gateway") }); + + let bridge = Arc::new(Mutex::new(BridgeMailbox::default())); + let workspace_status = Arc::new(Mutex::new(super::WorkspaceStatusMailbox::default())); + let (workspace_status_tx, workspace_status_rx) = mpsc::channel(); + drop(workspace_status_rx); + let mut app = OperatorApp::new( + bridge, + workspace_status, + workspace_status_tx, + None, + Arc::new(Mutex::new(RunOutcome::default())), + ); + app.operator_url = Some(base_url); + app.state + .reduce(TuiAction::SnapshotReceived(Box::new(snapshot))); + app.update(AppMessage::NextOptionPage); + app.update(AppMessage::NextOptionPage); + let visible = app + .state + .operator_lines(80, 8) + .into_iter() + .map(|line| line.to_plain_text()) + .collect::>(); + assert!(visible.iter().any(|line| line.contains("Options 3/4"))); + assert!(visible.iter().any(|line| line.contains("Allow option 11"))); + app.choose_operator_option(3); + let command = tokio::time::timeout(Duration::from_secs(3), commands_rx.recv()) + .await + .expect("terminal permission timeout") + .expect("terminal permission command"); + assert!( + matches!(command.answer, OperatorAnswer::Permission { ref option_id } if option_id == "opaque-allow-11") + ); + assert_eq!(command.interaction.rpc_id, "0"); + command.reply.send(Ok(())).expect("permission receipt"); + for _ in 0..30 { + app.update(AppMessage::Tick); + if !app.operator_response_pending { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(app.state.status_line, "ACP operator response accepted"); + + app.update(AppMessage::NextOperator); + app.update(AppMessage::NextOptionPage); + app.update(AppMessage::NextOptionPage); + let visible = app + .state + .operator_lines(80, 8) + .into_iter() + .map(|line| line.to_plain_text()) + .collect::>(); + assert!(visible.iter().any(|line| line.contains("Region 11"))); + app.choose_operator_option(3); + assert!(app.state.operator_answers.contains_key("question")); + app.operator_decision('s'); + let command = tokio::time::timeout(Duration::from_secs(3), commands_rx.recv()) + .await + .expect("terminal question timeout") + .expect("terminal question command"); + assert!( + matches!(command.answer, OperatorAnswer::Question { ref answers } + if answers.len() == 1 && answers[0].question_id == "region" + && answers[0].selected_option_ids == ["west-11"]) + ); + command.reply.send(Ok(())).expect("question receipt"); + for _ in 0..30 { + app.update(AppMessage::Tick); + if !app.operator_response_pending { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(app.state.status_line, "ACP operator response accepted"); + assert!(!app.state.operator_answers.contains_key("question")); + server.abort(); + } + + #[test] + fn snapshot_removal_discards_stale_operator_form_selections() { + let mut state = TuiState::default(); + state + .operator_answers + .insert("closed-form".into(), vec![vec!["west".into()]]); + state.reduce(TuiAction::SnapshotReceived(Box::new(fixture(2, 1)))); + assert!(state.operator_answers.is_empty()); + } + #[test] fn keeps_detail_visible_in_narrow_layouts() { let mut state = TuiState::default(); diff --git a/crates/opensymphony-tui/tests/reducer.rs b/crates/opensymphony-tui/tests/reducer.rs index fd0585972..c767207f2 100644 --- a/crates/opensymphony-tui/tests/reducer.rs +++ b/crates/opensymphony-tui/tests/reducer.rs @@ -57,6 +57,7 @@ fn fixture_with_identifiers(sequence: u64, identifiers: &[String]) -> SnapshotEn .iter() .enumerate() .map(|(index, identifier)| IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: identifier.clone(), title: format!("Issue {identifier}"), @@ -122,6 +123,7 @@ fn reordered_fixture(sequence: u64, identifiers: &[&str]) -> SnapshotEnvelope { .iter() .enumerate() .map(|(index, identifier)| IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: (*identifier).to_owned(), title: format!("Issue {index}"), diff --git a/crates/opensymphony-workflow/src/acp.rs b/crates/opensymphony-workflow/src/acp.rs index d00f8b529..373925858 100644 --- a/crates/opensymphony-workflow/src/acp.rs +++ b/crates/opensymphony-workflow/src/acp.rs @@ -31,6 +31,25 @@ pub struct AcpProfile { pub extensions: Vec, #[serde(default)] pub session: AcpSessionConfig, + #[serde(default)] + pub permissions: AcpPermissionConfig, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AcpPermissionConfig { + #[serde(default)] + pub mode: AcpPermissionPolicy, +} + +/// `allow_once` is an explicit operator authorization for unattended runs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AcpPermissionPolicy { + #[default] + Operator, + Deny, + AllowOnce, } /// Explicit advertised session selections. Values are opaque IDs, never credentials. diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index 20c45bc73..bf22c7432 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -1,5 +1,7 @@ mod acp; -pub use acp::{AcpAuth, AcpConfig, AcpProfile, AcpSessionConfig}; +pub use acp::{ + AcpAuth, AcpConfig, AcpPermissionConfig, AcpPermissionPolicy, AcpProfile, AcpSessionConfig, +}; mod error; mod loader; mod model; diff --git a/docs/architecture.md b/docs/architecture.md index 9644e8797..918e46762 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,6 +17,20 @@ The control-plane issue snapshot may carry an optional sanitized operator projection for repository, parent, lease, repair, memory, containment, provider, verification, and cleanup facts. Missing facts remain unknown. No client infers completion, permission, or workspace confinement from absence. +An ACP run may also publish ephemeral, bound operator interactions. The +orchestrator actor owns pending decisions; gateway clients submit a response +command, and the active ACP worker returns it to the original RPC responder. +The actor validates and reserves a decision, then waits for the ACP input-sink +flush in an owned task. A completion message returns to the actor to settle the +pending decision and gateway receipt; other issues, callbacks, ticks, and +shutdown remain responsive during that wait. An in-flight decision excludes a +duplicate response. +Worker callback reports wake that actor for immediate application and snapshot +publication, independently of the tracker polling interval. +The run loop selects an event before mutating scheduler state, so a newly +arriving callback or operator command cannot cancel an in-progress tick. +Pending interactions are discarded on completion, cancellation, expiry, or +restart and cannot be reconstructed from stored evidence. ## 2. Layered design @@ -522,7 +536,13 @@ processes use the existing process-group or Windows Job Object supervisors. Each retained prompt first retires the prior callback epoch through a bounded, cancellable preparation step while the owner continues servicing commands. Only then does it persist submission and dispatch the prompt. Session config responses -and updates are committed in SDK dispatch order before prompt completion. No OpenHands server or client participates in this launch path. +and updates are committed in SDK dispatch order before prompt completion. The +prompt response revokes its callback epoch before an adjacent request is +dispatched; automatic permission policy also requires that live epoch. +Callback closures use a bounded channel wait so a burst of open requests cannot +silently drop the expiry signal. A receiver that stops draining fails the turn; +worker completion clears its pending interactions. No +OpenHands server or client participates in this launch path. diff --git a/docs/configuration.md b/docs/configuration.md index 7451f25a5..9402acc21 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -948,8 +948,18 @@ acp: AGENT_API_KEY: OPERATOR_AGENT_API_KEY auth: method_id: agent_login + permissions: + mode: operator ``` +`permissions.mode` defaults to `operator`, which waits for a live operator +response. `deny` selects an offered `reject_once` option; `allow_once` selects +an offered `allow_once` option for trusted unattended profiles. If the agent +does not offer the configured kind, the callback is cancelled. Neither policy +invents an option ID or grants lasting access. Operator-mode runs require an +attached response path and use the callback deadline configured by the ACP +client limits. + Central configuration migration transfers `routing.harness_profile` and the full `acp.profiles` map from the legacy workflow before rewriting its prompt body. Environment references remain names throughout migration. At launch, each source @@ -1050,8 +1060,9 @@ named `opensymphony-memory` with the issued Authorization header. Resolved value stay out of the profile. Every stdio MCP argument value is structurally masked in captured requests; credential option values in separate and equals forms, including generic header values, are also redacted from echoed events and -stderr. The peer receives the exact host-supplied attachment. Operator -responses and extensions remain separate slices. +stderr. The peer receives the exact host-supplied attachment. ACP operator +responses use the scheduler-owned pending request path; arbitrary vendor +extensions remain separately gated. ACP credential arguments such as `--access-token`, `--oauth2-bearer`, `--client-secret`, and `--pat` are rejected in separate-value and equals forms, diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 00903697e..95fdf38f9 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -122,8 +122,11 @@ ACP v1 JSON-RPC 2.0, UTF-8 and LF framing. Start, continuation, cancellation, recovery and terminal cleanup use the shared worker and scheduler boundaries. `/api/v1/capabilities` separates generic adapter support (`harnesses`) from -configured profile preflight readiness (`harness_profiles`). Preflight checks -profile shape, executable availability and environment references, including +configured profile preflight readiness (`harness_profiles`). The desktop +`gateway_capabilities` command reads this response from its attached gateway +with the configured connection credentials, so ACP availability and profile +readiness match the daemon the operator is using. Preflight checks profile +shape, executable availability and environment references, including checkout-only credential exclusions. Executable lookup honors profile-mapped `PATH` values and requires absolute search directories because no issue cwd is available during preflight. It does not @@ -150,10 +153,9 @@ only recognized successful reasons complete the worker successfully. The known-secret matcher redacts that reason before it reaches durable state or a worker summary, independently of source-frame redaction. -Operator responses, writer transfer and IDE presentation follow the +Writer transfer and IDE presentation follow the [ACP runtime task package](tasks/acp-runtime-ide-task-package.yaml). Permission -requests produce a scheduler-visible waiting event and receive the protocol -cancellation response until operator handling is available. Unknown stop reasons +requests now use a live scheduler-owned operator response path. Unknown stop reasons and refusals stop automatic retry; uncertain submission or cancellation fences cleanup until execution risk is reconciled. @@ -164,8 +166,10 @@ cleanup until execution risk is reconciled. | `terminal/*` | Opt-in; create/output/wait/kill/release, connection-local random IDs, immutable host environment, contained cwd, bounded UTF-8 output. | | Session configuration | Advertised select options and grouped values; explicit model/mode choices; legacy modes; ordered updates. | | MCP | Required host-scoped stdio attachments; HTTP/SSE only after agent negotiation. | -| Permission | Cancelled response until operator routing is implemented. | -| Interactive auth and elicitation | Not advertised. | +| Permission | Offered opaque choices, `operator`/`deny`/`allow_once` policy, deadline and cancellation. | +| Structured question | Standard `elicitation/create` required string-enum and string-array-enum choice forms through gateway inputs, with accept, decline and cancel. | +| Plan approval | Typed gateway and client control; vendor method registration is handled by COE-613. | +| Interactive auth and elicitation | Form-only capability is advertised; free-text, numeric, optional, constrained, request-scoped and URL elicitation are unavailable. | The production worker enables file and terminal callbacks for its verified issue workspace and supplies the active scoped memory grant as a host-owned MCP diff --git a/docs/operations.md b/docs/operations.md index 69e506727..efe7a5e8a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1037,6 +1037,51 @@ Scheduled ACP interrupts identify the active worker by issue ID and issue identifier, then verify its current owner, generation, run and conversation before cancellation. +ACP permission and standard `elicitation/create` choice-form requests appear as +pending interactions for the selected run. Form capability advertises only the +form mode; URL elicitation is disabled. The supported form schema has one to +eight required string enum or string-array enum fields, with at most 32 offered +choices per field. Unsupported constraints, free-text fields, URL mode, and +secret prompts are rejected without exposing them in public snapshots. Web and +desktop Run Detail panels use offered permission option IDs and multiple-choice +form controls. Plan approval controls are typed for a registered extension; +Cursor method registration is handled separately. FrankenTUI shows a pending +request at the top of Issue Detail: `o` cycles requests, `,` and `.` page +through offered options, `1`–`4` selects a visible option, `[` and `]` cycle +questions, `s` submits complete answers, and `x` cancels. Question `n` declines. +The gateway exposes permissions and plans at `/api/v1/runs/{run_id}/approvals`, questions at +`/api/v1/runs/{run_id}/inputs`, and accepts bound responses through +`/api/v1/actions/dispatch`. A response must carry the live request, run, +issue, session, generation, and RPC binding token. The ACP responder retains the +original peer RPC ID privately; clients echo a generated public token. Stale, +duplicate, expired, and invalid option/answer submissions are rejected. A waiting interaction pauses +stall detection only until its deadline. Disconnect, cancellation, completion, +and restart clear the live responder; operators must wait for a fresh request. +Automatic `allow_once` and `deny` permission decisions require the same active +turn and are cancelled when a peer asks after its prompt has completed. +Callback arrivals and closures publish updated snapshots without waiting for +the next tracker poll. Timed-out callbacks retain their closure notification +through bounded channel backpressure, so pending requests clear when the worker +drains the queue. +An un-routed form request receives an ACP `cancel` response so a direct client +turn can continue. An operator-policy permission request without a route fails +the turn. A worker response that misses its acknowledgement deadline is fenced +before a failure receipt and remains pending for another answer attempt. An +answer already claimed by the ACP callback waits until the response frame is +flushed to the peer's input sink; a write failure returns a failed receipt. +The scheduler actor enqueues the response and applies the resulting receipt +when its worker-completion message arrives, so a slow peer does not hold up +tracker ticks, other issue updates, or shutdown. The terminal client waits for +the authoritative receipt without a total HTTP timeout; it bounds connection +establishment separately. Web and desktop invalidate an in-flight detail refresh +when a local operator answer is accepted, keeping answered controls removed. +The gateway also fences its queued command when its HTTP delivery deadline +expires or the handler closes. Once the run loop claims a command, the gateway +waits for the scheduler result instead of returning a premature timeout. +Only requests accepted into the scheduler's live pending set emit waiting +activity. Web and desktop retain selected form choices across live refreshes +while the same request remains pending. + ## Current model diff --git a/docs/specs/acp-harness-adapter.md b/docs/specs/acp-harness-adapter.md index da19c16a2..d5a460615 100644 --- a/docs/specs/acp-harness-adapter.md +++ b/docs/specs/acp-harness-adapter.md @@ -278,13 +278,11 @@ reject unauthorized alternate roots. Bind terminal IDs and file requests to the owning connection/session. Process supervision and callback containment are not a sandbox for a trusted local agent that also has direct host access. -The current [gateway approvals handler](https://github.com/kumanday/OpenSymphony/blob/50d8bb777d828515ffacdcd2c2ce9bbc9d3dba52/crates/opensymphony-gateway/src/lib.rs#L5202) -returns an empty list, and `ApprovalDecision` has no worker response path in the -inspected CLI/orchestrator code. Therefore operator support requires real wiring: -worker request event → orchestrator-owned pending request → snapshot/gateway → -validated operator command → worker response channel. Reuse `ApprovalRequest` -and `ActionReceipt` for decisions; add a separate input-request payload for -questions. Never fake successful permission forwarding because a DTO exists. +The operator path is worker request event → orchestrator-owned pending request → +snapshot/gateway → validated operator command → worker response channel. +Permissions and plans appear in `ApprovalRequest`; structured questions appear +in the run inputs endpoint. The gateway returns an `ActionReceipt` only after +the live worker acknowledges delivery to the original RPC responder. Define explicit profile policies: `operator`, `deny`, and `allow_once` for an operator-authorized unattended environment. Default to `operator`; headless diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 2630983d2..37698169e 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -654,6 +654,38 @@ adjacent response/update pair is dispatched, and saturate callback output while a peer stops reading stdin. Count and encoded-byte budgets cover successful, unknown and invalid permission callbacks; sequential round trips verify that completed transport writes release reservations. +The native ACP peer also verifies `elicitation/create` form accept, decline and +cancel responses, private large numeric peer RPC IDs with generated public +binding tokens, disabled Cursor methods, and form-only +capability advertisement. The production scheduler/gateway fixture routes a +permission and a choice form through HTTP action receipts to the blocked peer. +It uses a five-minute tracker interval and processes both callbacks through +worker-update wakeups without another tracker tick. +An un-routed native form receives protocol cancellation without failing its +turn, and a delayed worker-consume regression verifies that a timed-out +operator answer cannot reach the ACP callback after its failure receipt. +The scheduler retains a live request when the backend response queue is full or +an unconsumed answer expires, allowing a new submission. An output-reservation +test verifies that an operator answer is acknowledged only after the ACP input +sink flushes its response frame, and sink failure fails every queued receipt. +An early-finish native peer verifies that a completed prompt wakes the +scheduler and clears its pending request even if callback closure loses the +race, with tracker polling set to five minutes. +The peer writes its round-trip completion marker by atomic replacement after +closing the JSON file, so marker existence means the payload is readable. +Gateway timeout and handler-drop tests verify that delayed commands cannot +cross the scheduler application fence; a claimed command waits for its actual +acknowledgement. A concurrent reservation test holds one ACP SDK enqueue while +another callback responds and verifies output reservation and enqueue order. +The run-loop wake regression queues a callback notification and an operator +command during a selected tick, verifies that tick completes, and then drains +both queued events. A delayed ACP flush regression verifies that the actor can +tick, process a worker wake, and receive shutdown while the acknowledgement is +pending; the scheduler rejects a duplicate response and applies the eventual +receipt through its own completion path. A malformed permission callback produces no waiting event; +the routed production requests do. The shared web/desktop test retains two +form selections after refresh, submits both answers, and drops a refresh that +fetched the answered request before the successful submission. Native Unix tests replace the bound workspace root after service creation and verify file reads, atomic writes, and new terminal cwd remain on the original directory inode. They also swap a pinned terminal directory for an external @@ -706,7 +738,14 @@ Callback integration covers cancellation followed by a fresh turn on the same process, stale terminal rejection, live configuration and secret-safe host-policy/ MCP grant compatibility checks. Normal-completion tests send adjacent late file and terminal callbacks, verify rejection while idle and process reaping before -completion, then start a fresh callback epoch. Pre-prompt tests reject file and +completion, then start a fresh callback epoch. A permission-policy peer checks +that `allow_once` selects the offered option during a turn and cancels a +permission request adjacent to the prompt result, both with and without an +operator route. A saturated two-slot operator event channel test holds two +`Opened` events until both peer callbacks time out, then requires both matching +`Closed` events to arrive while the prompt remains active. Scheduler tests +verify that closure removes the pending interaction and resumes the idle clock. +Pre-prompt tests reject file and terminal requests after session binding. File and terminal-output tests verify exact wire contents with structural redaction in retained history and evidence. Configuration tests check preparation-frame attribution on success, cancellation diff --git a/packages/api-client/__tests__/run-actions.test.ts b/packages/api-client/__tests__/run-actions.test.ts index 224e1ab17..a1cf65474 100644 --- a/packages/api-client/__tests__/run-actions.test.ts +++ b/packages/api-client/__tests__/run-actions.test.ts @@ -102,10 +102,25 @@ describe("MockGatewayTransport action methods", () => { }); it("approvalDecision returns a receipt correlated to the approval", async () => { + const dispatch = jest.spyOn(transport, "dispatchAction"); const result = await transport.approvalDecision("approval-1", "approved", "approved for test"); assertReceiptShape(result); expect(result.correlation_id).toContain("approval-approval-1-"); expect(result.status).toBe("accepted"); + expect(dispatch.mock.calls[0][0].target_entity).toEqual({ entity_kind: "approval", entity_id: "approval-1" }); + }); + + it("routes ACP approval decisions by the public issue identifier", async () => { + const dispatch = jest.spyOn(transport, "dispatchAction"); + const interaction = { + request_id: "request-1", run_id: "run-1", issue_id: "internal-1", + issue_identifier: "COE-612", session_id: "session-1", generation: 1, + rpc_id: "7", kind: "permission" as const, title: "Permission", + options: [], questions: [], requested_at: "2026-09-23T00:00:00Z", + expires_at: "2026-09-24T00:00:00Z", + }; + await transport.approvalDecision("request-1", "approved", undefined, interaction, "allow"); + expect(dispatch.mock.calls[0][0].target_entity).toEqual({ entity_kind: "run", entity_id: "COE-612" }); }); it("setActionReceipt overrides generated receipts for a correlation id", async () => { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index e8e9b23bf..53e2495d5 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -10,6 +10,7 @@ import type { ActionDispatch, ActionReceipt, ApprovalRequest, + OperatorInteraction, PlanningSessionSummary, RunStatus, ReleaseReason, @@ -38,6 +39,7 @@ export { binaryFramesAdvertised, encodeBinaryFrame, decodeBinaryFrame, + operatorBinding, } from "./transports.js"; export type { TauriChannel, TauriRuntime } from "./transports.js"; export { MockGatewayTransport } from "./mock.js"; @@ -95,6 +97,7 @@ export interface GatewayTransport { runFiles(runId: string): Promise; runDiffs(runId: string, filePath?: string): Promise; runApprovals(runId: string): Promise; + runInputs(runId: string): Promise; runValidation(runId: string): Promise; terminalSnapshot(runId: string, terminalId: string, cursor?: number): Promise; terminalSearch(runId: string, terminalId: string, query: string): Promise; @@ -131,7 +134,7 @@ export interface ActionCapableTransport extends GatewayTransport { rehydrateRun(runId: string, operationId?: string): Promise; commentRun(runId: string, text: string): Promise; createFollowup(runId: string, payload: unknown): Promise; - approvalDecision(approvalId: string, decision: "approved" | "rejected", explanation?: string): Promise; + approvalDecision(approvalId: string, decision: "approved" | "rejected", explanation?: string, interaction?: OperatorInteraction, optionId?: string): Promise; openWorkspace(runId: string): Promise; debugRun(runId: string): Promise; } @@ -188,6 +191,7 @@ export type { ActionDispatch, ActionReceipt, ApprovalRequest, + OperatorInteraction, PlanningSessionSummary, RunStatus, ReleaseReason, diff --git a/packages/api-client/src/mock.ts b/packages/api-client/src/mock.ts index 8aef45ecb..a7122f2cc 100644 --- a/packages/api-client/src/mock.ts +++ b/packages/api-client/src/mock.ts @@ -22,6 +22,7 @@ import type { FileDiffPage, RunValidationSummary, ApprovalRequest, + OperatorInteraction, AuthErrorCode, } from "@opensymphony/gateway-schema"; import type { GatewayTransport, ActionCapableTransport } from "./index.js"; @@ -40,6 +41,7 @@ export class MockGatewayTransport implements GatewayTransport, ActionCapableTran private mockRunFiles: Map = new Map(); private mockRunDiffs: Map = new Map(); private mockRunApprovals: Map = new Map(); + private mockRunInputs: Map = new Map(); private mockRunValidation: Map = new Map(); private mockTerminalSnapshot: Map; private mockEvents: GatewayEnvelope[] = []; @@ -273,6 +275,10 @@ export class MockGatewayTransport implements GatewayTransport, ActionCapableTran return this.mockRunApprovals.get(runId) ?? []; } + async runInputs(runId: string): Promise { + return this.mockRunInputs.get(runId) ?? []; + } + async runValidation(runId: string): Promise { return ( this.mockRunValidation.get(runId) ?? { @@ -544,14 +550,18 @@ export class MockGatewayTransport implements GatewayTransport, ActionCapableTran approvalId: string, decision: "approved" | "rejected", explanation?: string, + interaction?: OperatorInteraction, + optionId?: string, ): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `approval-${approvalId}-${crypto.randomUUID()}`, action_kind: "approval_decision", - target_entity: { entity_kind: "approval", entity_id: approvalId }, - payload: { decision, explanation }, - idempotency_key: `approval-${approvalId}-${decision}`, + target_entity: interaction + ? { entity_kind: "run", entity_id: interaction.issue_identifier } + : { entity_kind: "approval", entity_id: approvalId }, + payload: interaction ? { decision, explanation, option_id: optionId, request_id: interaction.request_id, run_id: interaction.run_id, issue_id: interaction.issue_id, session_id: interaction.session_id, generation: interaction.generation, rpc_id: interaction.rpc_id } : { decision, explanation }, + idempotency_key: interaction ? undefined : `approval-${approvalId}-${decision}`, }); } @@ -631,6 +641,10 @@ export class MockGatewayTransport implements GatewayTransport, ActionCapableTran this.mockRunApprovals.set(runId, approvals); } + setRunInputs(runId: string, inputs: OperatorInteraction[]): void { + this.mockRunInputs.set(runId, inputs); + } + /** Set mock validation summary for a run. */ setRunValidation(runId: string, summary: RunValidationSummary): void { this.mockRunValidation.set(runId, summary); diff --git a/packages/api-client/src/transports.ts b/packages/api-client/src/transports.ts index 2ef15a284..53284ffda 100644 --- a/packages/api-client/src/transports.ts +++ b/packages/api-client/src/transports.ts @@ -18,6 +18,7 @@ import type { FileDiffPage, RunValidationSummary, ApprovalRequest, + OperatorInteraction, ConnectionProfile, } from "@opensymphony/gateway-schema"; import { pageCursorFirst } from "@opensymphony/gateway-schema"; @@ -25,6 +26,17 @@ import type { GatewayTransport, GatewayTransportConfig, ActionCapableTransport } import { stableHash, stableHashJson } from "./util.js"; import { GatewayRequestError, authErrorCodeForStatus } from "./errors.js"; +export function operatorBinding(interaction: OperatorInteraction) { + return { + request_id: interaction.request_id, + run_id: interaction.run_id, + issue_id: interaction.issue_id, + session_id: interaction.session_id, + generation: interaction.generation, + rpc_id: interaction.rpc_id, + }; +} + /** Best-effort parse of a response body as JSON; returns the raw string on failure. */ function tryParseJson(raw: string): unknown { if (!raw) return undefined; @@ -140,6 +152,13 @@ export class HttpGatewayTransport implements GatewayTransport, ActionCapableTran return response.approvals ?? []; } + async runInputs(runId: string): Promise { + const response = await this.fetchJson( + `${this.baseUri}/api/v1/runs/${encodeURIComponent(runId)}/inputs`, + ) as { inputs?: OperatorInteraction[] }; + return response.inputs ?? []; + } + async runValidation(runId: string): Promise { const response = await this.fetchJson( `${this.baseUri}/api/v1/runs/${encodeURIComponent(runId)}/validation`, @@ -446,14 +465,18 @@ export class HttpGatewayTransport implements GatewayTransport, ActionCapableTran approvalId: string, decision: "approved" | "rejected", explanation?: string, + interaction?: OperatorInteraction, + optionId?: string, ): Promise { return this.dispatchAction({ schema_version: { major: 1, minor: 0, patch: 0 }, correlation_id: `approval-${approvalId}-${crypto.randomUUID()}`, action_kind: "approval_decision", - target_entity: { entity_kind: "approval", entity_id: approvalId }, - payload: { decision, explanation }, - idempotency_key: `approval-${approvalId}-${decision}`, + target_entity: interaction + ? { entity_kind: "run", entity_id: interaction.issue_identifier } + : { entity_kind: "approval", entity_id: approvalId }, + payload: interaction ? { decision, explanation, option_id: optionId, ...operatorBinding(interaction) } : { decision, explanation }, + idempotency_key: interaction ? undefined : `approval-${approvalId}-${decision}`, }); } @@ -768,6 +791,13 @@ export class WebSocketTransport implements GatewayTransport { return response.approvals ?? []; } + async runInputs(runId: string): Promise { + const response = await this.get<{ inputs?: OperatorInteraction[] }>( + `/api/v1/runs/${encodeURIComponent(runId)}/inputs`, + ); + return response.inputs ?? []; + } + async runValidation(runId: string): Promise { return this.get( `/api/v1/runs/${encodeURIComponent(runId)}/validation`, @@ -1312,6 +1342,12 @@ export class TauriChannelTransport implements GatewayTransport { }).then((r) => r.approvals ?? []); } + async runInputs(runId: string): Promise { + return this.invoke<{ inputs: OperatorInteraction[] }>("run_inputs", { + run_id: runId, + }).then((r) => r.inputs ?? []); + } + async runValidation(runId: string): Promise { return this.invoke("run_validation", { run_id: runId }); } diff --git a/packages/gateway-schema/src/action.ts b/packages/gateway-schema/src/action.ts index 053a2b9fc..5d8781235 100644 --- a/packages/gateway-schema/src/action.ts +++ b/packages/gateway-schema/src/action.ts @@ -14,6 +14,8 @@ export type ActionKind = | "transition_issue" | "create_followup" | "approval_decision" + | "input_response" + | "plan_decision" | "publish_plan" | "task_graph_milestone" | "task_graph_issue" diff --git a/packages/gateway-schema/src/approval.ts b/packages/gateway-schema/src/approval.ts index 6f0a05544..12bdbeae7 100644 --- a/packages/gateway-schema/src/approval.ts +++ b/packages/gateway-schema/src/approval.ts @@ -16,6 +16,27 @@ export type ApprovalStatus = | "passed" | "failed"; +export interface OperatorOption { id: string; label: string; kind: string } +export interface OperatorQuestion { id: string; prompt: string; options: OperatorOption[]; allow_multiple: boolean } +export interface OperatorQuestionAnswer { question_id: string; selected_option_ids: string[] } +export interface OperatorInteraction { + request_id: string; + run_id: string; + issue_id: string; + issue_identifier: string; + session_id: string; + generation: number; + /** Lossless JSON encoding of the original RPC ID. Echo as an opaque string. */ + rpc_id: string; + kind: "permission" | "question" | "plan_approval"; + title: string; + options: OperatorOption[]; + questions: OperatorQuestion[]; + plan?: string; + requested_at: string; + expires_at: string; +} + /** Actor that requested or approved an action. */ export interface ApprovalActor { actor_id: string; @@ -51,6 +72,7 @@ export interface ApprovalRequest { title: string; description: string; proposed_action?: unknown; + operator_interaction?: OperatorInteraction; /** Actor who requested the approval. */ actor?: ApprovalActor; /** Target context (file, command, issue, run) for the request. */ diff --git a/packages/gateway-schema/src/index.ts b/packages/gateway-schema/src/index.ts index e6d6e2c4a..9feebf51b 100644 --- a/packages/gateway-schema/src/index.ts +++ b/packages/gateway-schema/src/index.ts @@ -155,6 +155,10 @@ export type { ApprovalRiskLevel, ApprovalRiskSummary, ApprovalRequest, + OperatorInteraction, + OperatorOption, + OperatorQuestion, + OperatorQuestionAnswer, ActionStatus, ActionReceiptStatus, ExpectedFollowup, diff --git a/packages/ui-core/__tests__/app-shell.test.ts b/packages/ui-core/__tests__/app-shell.test.ts index 6daab1c7a..d9472f6bc 100644 --- a/packages/ui-core/__tests__/app-shell.test.ts +++ b/packages/ui-core/__tests__/app-shell.test.ts @@ -56,6 +56,9 @@ import type { RunDetail, RunEventPage, TaskGraphSnapshot, + ApprovalRequest, + OperatorInteraction, + ActionDispatch, } from "@opensymphony/gateway-schema"; import { defaultModelProfiles } from "@opensymphony/gateway-schema"; @@ -918,6 +921,78 @@ describe("OpenSymphonyApp mount", () => { await handle.destroy(); }); + it("submits bound ACP permission and structured question answers from Run Detail", async () => { + const transport = buildTransport() as MockGatewayTransport; + const dispatch = jest.spyOn(transport, "dispatchAction"); + const binding: OperatorInteraction = { + request_id: "operator-1", run_id: "run-worker-1", issue_id: "issue-1", issue_identifier: "COE-449", + session_id: "session-1", generation: 2, rpc_id: "0", kind: "permission", title: "Run tests", + options: [{ id: "allow-once", label: "Allow once", kind: "allow_once" }], questions: [], + requested_at: "2026-09-23T00:00:00Z", expires_at: "2026-09-23T00:05:00Z", + }; + const approval: ApprovalRequest = { + schema_version: schemaVersionV1(), approval_id: binding.request_id, run_id: binding.run_id, + issue_id: binding.issue_id, kind: "tool_use", title: binding.title, description: "Run tests", + operator_interaction: binding, requested_at: binding.requested_at, expires_at: binding.expires_at, + status: "pending", correlation_id: binding.request_id, + }; + const question: OperatorInteraction = { ...binding, request_id: "operator-2", kind: "question", title: "Choose region", + options: [], questions: [{ id: "region", prompt: "Region?", allow_multiple: false, + options: [{ id: "west", label: "West", kind: "choice" }] }, + { id: "zone", prompt: "Zone?", allow_multiple: false, + options: [{ id: "north", label: "North", kind: "choice" }] }] }; + transport.setRunApprovals("desktop-alpha", [approval]); + transport.setRunInputs("desktop-alpha", [question]); + transport.setRunApprovals("COE-449", [approval]); + transport.setRunInputs("COE-449", [question]); + const root = document.createElement("div"); + document.body.appendChild(root); + const handle = renderOpenSymphonyApp({ root, mode: "desktop", transport }); + await flushUntil(() => root.querySelector("[data-testid='operator-permission-option']") !== null); + (root.querySelector("[data-testid='operator-permission-option']") as HTMLButtonElement).click(); + await flushUntil(() => dispatch.mock.calls.length === 1); + const permissionAction = dispatch.mock.calls[0]?.[0] as ActionDispatch; + expect(permissionAction.action_kind).toBe("approval_decision"); + expect(permissionAction.target_entity.entity_id).toBe("COE-449"); + expect(permissionAction.payload).toMatchObject({ request_id: "operator-1", generation: 2, rpc_id: "0", option_id: "allow-once" }); + expect(permissionAction.idempotency_key).toBeUndefined(); + await flushUntil(() => root.querySelector("[data-testid='operator-input-answer']") !== null); + (root.querySelector("[data-testid='operator-input'] input[value='west']") as HTMLInputElement).click(); + (root.querySelector("[data-testid='operator-input'] input[value='north']") as HTMLInputElement).click(); + await handle.refresh(); + expect((root.querySelector("[data-testid='operator-input'] input[value='west']") as HTMLInputElement).checked).toBe(true); + expect((root.querySelector("[data-testid='operator-input'] input[value='north']") as HTMLInputElement).checked).toBe(true); + const inputRead = jest.spyOn(transport, "runInputs").mockRejectedValueOnce(new Error("temporary read failure")); + await handle.refresh(); + inputRead.mockRestore(); + await handle.refresh(); + expect((root.querySelector("[data-testid='operator-input'] input[value='west']") as HTMLInputElement).checked).toBe(true); + expect((root.querySelector("[data-testid='operator-input'] input[value='north']") as HTMLInputElement).checked).toBe(true); + const originalValidation = transport.runValidation.bind(transport); + let releaseValidation = () => {}; + const validationGate = new Promise((resolve) => { releaseValidation = resolve; }); + const validationRead = jest.spyOn(transport, "runValidation").mockImplementationOnce(async (runId) => { + await validationGate; + return originalValidation(runId); + }); + const inputReadDuringRefresh = jest.spyOn(transport, "runInputs"); + const refresh = (handle as unknown as { requestLiveRefresh(): Promise }).requestLiveRefresh(); + await flushUntil(() => validationRead.mock.calls.length > 0 && inputReadDuringRefresh.mock.calls.length > 0); + await new Promise((resolve) => setTimeout(resolve, 0)); // the refresh holds a stale input bundle + (root.querySelector("[data-testid='operator-input-answer']") as HTMLButtonElement).click(); + await flushUntil(() => dispatch.mock.calls.length === 2); + expect(dispatch.mock.calls[1]?.[0]).toMatchObject({ action_kind: "input_response", + payload: { request_id: "operator-2", outcome: "answered", answers: [ + { question_id: "region", selected_option_ids: ["west"] }, + { question_id: "zone", selected_option_ids: ["north"] }, + ] } }); + await flushUntil(() => root.querySelector("[data-testid='operator-input-answer']") === null); + releaseValidation(); + await refresh; + expect(root.querySelector("[data-testid='operator-input-answer']")).toBeNull(); + await handle.destroy(); + }); + it("restores graph surface state and lower columns across round trips", async () => { const root = document.createElement("div"); document.body.appendChild(root); diff --git a/packages/ui-core/__tests__/approval.test.ts b/packages/ui-core/__tests__/approval.test.ts index e1b6b47b8..f7f9d6f80 100644 --- a/packages/ui-core/__tests__/approval.test.ts +++ b/packages/ui-core/__tests__/approval.test.ts @@ -4,8 +4,16 @@ * Unit tests for the approval list renderer. */ -import type { ApprovalRequest } from "@opensymphony/gateway-schema"; -import { renderApprovalList, type ApprovalDecision } from "../src/approval.js"; +import type { ApprovalRequest, OperatorInteraction } from "@opensymphony/gateway-schema"; +import { renderApprovalList, renderOperatorInputs, type ApprovalDecision } from "../src/approval.js"; + +const operator: OperatorInteraction = { + request_id: "req-1", run_id: "run-1", issue_id: "issue-1", issue_identifier: "COE-612", + session_id: "session-1", generation: 1, rpc_id: "0", kind: "permission", title: "Use tool", + options: [{ id: "allow-once", label: "Allow once", kind: "allow_once" }, + { id: "deny-once", label: "Deny", kind: "reject_once" }], questions: [], + requested_at: "2026-09-23T00:00:00Z", expires_at: "2026-09-23T00:05:00Z", +}; function approvalFixture(opts: Partial & Pick): ApprovalRequest { const base: ApprovalRequest = { @@ -66,4 +74,24 @@ describe("renderApprovalList", () => { expect(html).toContain('data-approval-id="app-"-x"'); expect(html).toContain('data-approval-kind="cmd"-x"'); }); + + it("shows only offered ACP permission options and never generic approve controls", () => { + const html = renderApprovalList([approvalFixture({ approval_id: "req-1", status: "pending", operator_interaction: operator })], { onDecide: jest.fn() }); + expect(html).toContain('data-option-id="allow-once"'); + expect(html).toContain('data-option-id="deny-once"'); + expect(html).not.toContain('data-testid="approve-button"'); + expect(html).toContain('data-testid="operator-permission-cancel"'); + }); + + it("renders structured choices with decline and cancel but no free-text field", () => { + const html = renderOperatorInputs([{ ...operator, kind: "question", options: [], questions: [ + { id: "region", prompt: "Region?", allow_multiple: false, + options: [{ id: "east", label: "East", kind: "choice" }, { id: "west", label: "West", kind: "choice" }] }, + ] }]); + expect(html).toContain('type="radio"'); + expect(html).toContain('data-testid="operator-input-answer"'); + expect(html).toContain('data-testid="operator-input-decline"'); + expect(html).toContain('data-testid="operator-input-cancel"'); + expect(html).not.toContain('type="text"'); + }); }); diff --git a/packages/ui-core/src/app-shell.ts b/packages/ui-core/src/app-shell.ts index 737e5489f..bca6a3e71 100644 --- a/packages/ui-core/src/app-shell.ts +++ b/packages/ui-core/src/app-shell.ts @@ -8,6 +8,7 @@ import type { GatewayEnvelope, GatewayCapabilities, ApprovalRequest, + OperatorInteraction, RunAction, RunDetail, RunEvent, @@ -86,7 +87,7 @@ import { renderFileDiff, } from "./diff.js"; import { renderValidationSummary } from "./validation.js"; -import { renderApprovalList, type ApprovalDecision } from "./approval.js"; +import { renderApprovalList, renderOperatorInputs, type ApprovalDecision } from "./approval.js"; import { buildActionBarItems, renderActionBar, @@ -181,6 +182,7 @@ export interface GatewayReader { runDiffs?(runId: string, filePath?: string): Promise; runValidation?(runId: string): Promise; runApprovals?(runId: string): Promise; + runInputs?(runId: string): Promise; events?(fromCursor?: { sequence: number; partition: string }): AsyncIterable; /** Optional action dispatch for gateway-mediated mutations. */ dispatchAction?(action: ActionDispatch): Promise; @@ -285,6 +287,7 @@ interface AppState { eventLogModalOpen: boolean; runValidation: RunValidationSummary | null; runApprovals: ApprovalRequest[] | null; + runInputs: OperatorInteraction[] | null; lastActionReceipt: ActionReceipt | null; auditTrail: AuditTrailEntry[]; profiles: ConnectionProfile[]; @@ -338,6 +341,8 @@ interface RunDetailBundle { runEvents: RunEvent[]; runValidation: RunValidationSummary | null; runApprovals: ApprovalRequest[]; + runInputs: OperatorInteraction[]; + runInputsLoaded: boolean; warnings: string[]; } @@ -453,6 +458,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { /** Guards in-flight selectDiffFile loads (superseded by newer diff clicks or opens). */ private diffSelectSeq = 0; private readonly runOutlineCache = new Map(); + private readonly operatorSelections = new Map>(); /** Tracks bindEvents sites already attached per element (see listen()). */ private boundListeners = new WeakMap>(); @@ -495,6 +501,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { eventLogModalOpen: false, runValidation: null, runApprovals: null, + runInputs: null, lastActionReceipt: null, auditTrail: [], profiles, @@ -625,17 +632,36 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { } })(); + const inputs = (async () => { + try { + return { + values: typeof this.transport.runInputs === "function" ? await this.transport.runInputs(runId) : [], + loaded: true, + }; + } catch (error) { + warnings.push(`Operator inputs unavailable: ${errorMessage(error)}`); + return { values: [], loaded: false }; + } + })(); const [ { runFiles, selectedDiffPath, runDiff }, runEvents, runValidation, runApprovals, - ] = await Promise.all([filesAndDiff, events, validation, approvals]); - return { runId, runFiles, selectedDiffPath, runDiff, runCodeOverlay: null, runCodeOutline: null, runEvents, runValidation, runApprovals, warnings }; + { values: runInputs, loaded: runInputsLoaded }, + ] = await Promise.all([filesAndDiff, events, validation, approvals, inputs]); + return { runId, runFiles, selectedDiffPath, runDiff, runCodeOverlay: null, runCodeOutline: null, runEvents, runValidation, runApprovals, runInputs, runInputsLoaded, warnings }; } private applyRunDetailBundle(bundle: RunDetailBundle): void { const sameRun = this.state.runDetail?.run_id === bundle.runId; + if (!sameRun) this.operatorSelections.clear(); + if (bundle.runInputsLoaded) { + const liveInputIds = new Set(bundle.runInputs.map((input) => input.request_id)); + for (const requestId of this.operatorSelections.keys()) { + if (!liveInputIds.has(requestId)) this.operatorSelections.delete(requestId); + } + } const sameDiff = sameRun && this.state.selectedDiffPath === bundle.selectedDiffPath && runOutlineCacheKey(bundle.runId, bundle.selectedDiffPath ?? "", this.state.runDiff) @@ -648,6 +674,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { this.state.runEvents = bundle.runEvents; this.state.runValidation = bundle.runValidation; this.state.runApprovals = bundle.runApprovals; + this.state.runInputs = bundle.runInputs; if (bundle.warnings.length > 0) { this.state.connectionMessage = bundle.warnings[bundle.warnings.length - 1]; } @@ -823,6 +850,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { this.state.collapsedActivityEvents = new Set(); this.state.runValidation = null; this.state.runApprovals = null; + this.state.runInputs = null; } /** @@ -936,6 +964,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { this.state.collapsedActivityEvents = new Set(); this.state.runValidation = null; this.state.runApprovals = null; + this.state.runInputs = null; this.state.connectionMessage = `Task graph unavailable: ${errorMessage(error)}`; return; } @@ -956,6 +985,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { this.state.collapsedActivityEvents = new Set(); this.state.runValidation = null; this.state.runApprovals = null; + this.state.runInputs = null; this.state.selectedDiffPath = null; if (this.options.mode === "desktop") { // Completed pane data loads independently: a memory-server hiccup must @@ -1963,6 +1993,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { this.state.collapsedActivityEvents = new Set(); this.state.runValidation = null; this.state.runApprovals = null; + this.state.runInputs = null; this.state.selectedDiffPath = null; // An active issue the control plane does not track yet (e.g. just // promoted from Backlog, no run dispatched) has no run detail to @@ -2488,6 +2519,47 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { this.render(); } + private async submitOperatorInteraction( + interaction: OperatorInteraction, + actionKind: "approval_decision" | "input_response" | "plan_decision", + payload: Record, + ): Promise { + const action: ActionDispatch = { + schema_version: { major: 1, minor: 0, patch: 0 }, + correlation_id: `operator-${interaction.request_id}-${crypto.randomUUID()}`, + action_kind: actionKind, + target_entity: { entity_kind: "run", entity_id: interaction.issue_identifier }, + payload: { + request_id: interaction.request_id, + run_id: interaction.run_id, + issue_id: interaction.issue_id, + session_id: interaction.session_id, + generation: interaction.generation, + rpc_id: interaction.rpc_id, + ...payload, + }, + }; + try { + const receipt = await (this.transport.dispatchAction?.(action) ?? unsupportedAction(actionKind)); + this.state.lastActionReceipt = receipt; + if (receipt.status === "accepted") { + // A live refresh may have fetched this interaction before the answer + // completed. Invalidate that in-flight bundle before it can restore + // stale controls over the accepted local state. + this.interactionEpoch += 1; + this.state.runApprovals = this.state.runApprovals?.filter((item) => item.operator_interaction?.request_id !== interaction.request_id) ?? null; + this.state.runInputs = this.state.runInputs?.filter((item) => item.request_id !== interaction.request_id) ?? null; + this.operatorSelections.delete(interaction.request_id); + } + this.state.auditTrail.push({ timestamp: new Date().toISOString(), actor: "operator", action: actionKind, + target: interaction.request_id, status: receipt.status, details: receipt.reason }); + } catch (error) { + this.state.auditTrail.push({ timestamp: new Date().toISOString(), actor: "operator", action: actionKind, + target: interaction.request_id, status: "failed", details: errorMessage(error) }); + } + this.render(); + } + private async selectProject(projectId: string): Promise { this.interactionEpoch += 1; this.runOpenSeq += 1; @@ -3665,6 +3737,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { const validation = hasValidationSummary(this.state.runValidation) ? renderValidationSummary(this.state.runValidation) : ""; + const operatorInputs = this.state.runInputs?.length ? renderOperatorInputs(this.state.runInputs, this.operatorSelections) : ""; const approvals = this.state.runApprovals?.length ? renderApprovalList(this.state.runApprovals, { onDecide: (id, decision, explanation) => { @@ -3743,7 +3816,7 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { ${codeDeltaList} ${files} - ${validation || approvals ? `
${validation ? `
${validation}
` : ""}${approvals ? `
${approvals}
` : ""}
` : ""} + ${validation || approvals || operatorInputs ? `
${validation ? `
${validation}
` : ""}${approvals ? `
${approvals}
` : ""}${operatorInputs ? `
${operatorInputs}
` : ""}
` : ""} ${audit}
${escapeHtml(run.workspace_path ?? run.workspace_id ?? "workspace path unavailable")}
`, @@ -4713,6 +4786,61 @@ class OpenSymphonyApp implements OpenSymphonyAppHandle { }); }); + const pendingOperator = (id: string | undefined) => this.state.runApprovals?.find((item) => item.approval_id === id)?.operator_interaction + ?? this.state.runInputs?.find((item) => item.request_id === id); + this.options.root.querySelectorAll("[data-testid='operator-permission-option']").forEach((button) => { + this.listen(button, "operator-permission-option", "click", () => { + const interaction = pendingOperator(button.dataset.approvalId); + const decision = button.dataset.decision; + const optionId = button.dataset.optionId; + if (interaction?.kind === "permission" && optionId && (decision === "approved" || decision === "rejected")) { + void this.submitOperatorInteraction(interaction, "approval_decision", { decision, option_id: optionId }); + } + }); + }); + this.options.root.querySelectorAll("[data-testid='operator-permission-cancel'], [data-testid='operator-plan-approve'], [data-testid='operator-plan-reject'], [data-testid='operator-plan-cancel']").forEach((button) => { + this.listen(button, "operator-approval-response", "click", () => { + const interaction = pendingOperator(button.dataset.approvalId); + if (!interaction) return; + const kind = interaction.kind === "permission" ? "approval_decision" : "plan_decision"; + const decision = button.dataset.testid === "operator-plan-approve" ? "approved" + : button.dataset.testid === "operator-plan-reject" ? "rejected" : "cancelled"; + void this.submitOperatorInteraction(interaction, kind, { decision }); + }); + }); + this.options.root.querySelectorAll("[data-testid='operator-input']").forEach((form) => { + const interaction = pendingOperator(form.dataset.requestId); + if (interaction?.kind !== "question") return; + this.listen(form, "operator-input-choice", "change", () => { + const selected = new Map(); + for (const question of interaction.questions) { + selected.set(question.id, Array.from(form.querySelectorAll("input:checked")) + .filter((input) => input.name === `${interaction.request_id}:${question.id}`) + .map((input) => input.value)); + } + this.operatorSelections.set(interaction.request_id, selected); + }); + for (const [testId, outcome] of [ + ["operator-input-answer", "answered"], ["operator-input-decline", "declined"], ["operator-input-cancel", "cancelled"], + ] as const) { + const button = form.querySelector(`[data-testid='${testId}']`); + if (!button) continue; + this.listen(button, testId, "click", () => { + const answers = interaction.questions.map((question) => ({ + question_id: question.id, + selected_option_ids: this.operatorSelections.get(interaction.request_id)?.get(question.id) ?? [], + })); + if (outcome === "answered" && answers.some((answer) => answer.selected_option_ids.length === 0)) { + this.state.auditTrail.push({ timestamp: new Date().toISOString(), actor: "operator", action: "input_response", + target: interaction.request_id, status: "failed", details: "Select an option for every question." }); + this.render(); + return; + } + void this.submitOperatorInteraction(interaction, "input_response", { outcome, answers }); + }); + } + }); + // Three-pane task graph: collapse toggles, completed-tasks controls, // capsule deep links, and cross-pane edge geometry. diff --git a/packages/ui-core/src/approval.ts b/packages/ui-core/src/approval.ts index af749a035..adf944b08 100644 --- a/packages/ui-core/src/approval.ts +++ b/packages/ui-core/src/approval.ts @@ -1,4 +1,4 @@ -import type { ApprovalRequest } from "@opensymphony/gateway-schema"; +import type { ApprovalRequest, OperatorInteraction } from "@opensymphony/gateway-schema"; import { escapeHtml, escapeAttr } from "./html.js"; /** Decision a user can make on an approval request. */ @@ -25,7 +25,17 @@ export function renderApprovalList( ? `
${escapeHtml(approval.actor.display_name ?? approval.actor.actor_id)} (${escapeHtml(approval.actor.actor_kind)})
` : ""; // Only render decision buttons when there is a handler AND the approval is still pending. - const explain = opts?.onDecide && approval.status === "pending" + const operator = approval.operator_interaction; + const operatorChoices = operator && opts?.onDecide && approval.status === "pending" + ? operator.kind === "permission" + ? `
${operator.options.map((option) => { + const decision = ["allow_once", "allow_always"].includes(option.kind) ? "approved" + : ["reject_once", "reject_always"].includes(option.kind) ? "rejected" : ""; + return decision ? `` : ""; + }).join("")}
` + : `
` + : ""; + const explain = !operator && opts?.onDecide && approval.status === "pending" ? `
@@ -38,6 +48,7 @@ export function renderApprovalList( ${actor} ${target} ${risk} + ${operatorChoices} ${explain}
`; }) @@ -45,6 +56,23 @@ export function renderApprovalList( return `
${items}
`; } +/** Render bounded, offered multiple-choice ACP questions without a free-text field. */ +export function renderOperatorInputs( + inputs: OperatorInteraction[], + selections: ReadonlyMap> = new Map(), +): string { + return inputs.filter((input) => input.kind === "question").map((input) => ` +
+
${escapeHtml(input.title)}
+ ${input.questions.map((question) => `
${escapeHtml(question.prompt)} + ${question.options.map((option) => ``).join("")} +
`).join("")} + + + +
`).join(""); +} + function renderTargetContext(ctx: ApprovalRequest["target_context"]): string { if (!ctx) return ""; const parts: string[] = []; diff --git a/tests/acp.rs b/tests/acp.rs index 3392a77d7..7ae6a0d6f 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -1,7 +1,9 @@ use opensymphony::opensymphony_acp::{ AcpProfile, ClientError, ClientLimits, LaunchContext, run_turn, }; -use opensymphony::opensymphony_workflow::{AcpAuth, WorkflowDefinition}; +use opensymphony::opensymphony_workflow::{ + AcpAuth, AcpPermissionConfig, AcpPermissionPolicy, WorkflowDefinition, +}; use std::{ collections::{BTreeMap, BTreeSet}, path::Path, @@ -25,6 +27,11 @@ fn profile(mode: &str) -> AcpProfile { required_capabilities: vec![], extensions: vec![], session: Default::default(), + // These transport tests use a standalone client without an operator + // route; deny keeps their legacy permission callback deterministic. + permissions: AcpPermissionConfig { + mode: AcpPermissionPolicy::Deny, + }, } } fn context(root: &Path) -> LaunchContext { @@ -53,6 +60,45 @@ fn limits() -> ClientLimits { } } +#[tokio::test] +async fn acp_operator_policy_without_response_path_fails_visibly() { + let root = tempfile::tempdir().expect("temp"); + let mut config = profile("complete"); + config.permissions.mode = AcpPermissionPolicy::Operator; + let run = run_turn( + &config, + context(root.path()), + "requires operator".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("launch"); + assert!(matches!( + run.outcome, + Err(ClientError::Protocol { submitted: true }) + )); + assert!(run.process_reaped); +} + +#[tokio::test] +async fn standard_form_without_operator_route_is_cancelled_without_failing_turn() { + let root = tempfile::tempdir().expect("temp"); + let run = run_turn( + &profile("form_no_route"), + context(root.path()), + "form fallback".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("launch"); + assert!(run.outcome.expect("protocol cancellation").succeeded()); + assert!(run.process_reaped); +} + #[tokio::test(flavor = "current_thread")] async fn acp_turn_without_client_deadline_survives_five_minutes_of_virtual_time() { let root = tempfile::tempdir().expect("temp"); @@ -1181,7 +1227,8 @@ fn acp_adapter_exposes_execution_and_explicit_gaps() { && capability.actions.start_run && capability.cancellation.acknowledges_cancel ); - assert!(!capability.actions.approve && !capability.pause_resume.resume); + assert!(capability.actions.approve && capability.approvals.human_decision); + assert!(!capability.pause_resume.resume); assert!(!capability.feature_gaps.is_empty()); } diff --git a/tests/acp_session_host.rs b/tests/acp_session_host.rs index fcdc956d4..75e86c3cc 100644 --- a/tests/acp_session_host.rs +++ b/tests/acp_session_host.rs @@ -1,3 +1,7 @@ +use opensymphony::opensymphony_gateway_schema::approval::{ + OperatorAnswer, OperatorInteractionKind, OperatorQuestionAnswer, +}; +use opensymphony::opensymphony_workflow::AcpPermissionPolicy; use opensymphony::{opensymphony_acp::*, opensymphony_workspace::*}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -91,6 +95,7 @@ async fn launch(root: &Path, issue: &str, mode: &str) -> SessionLaunch { workspace, identity, profile: AcpProfile { + permissions: Default::default(), command: "python3".into(), args: vec![ format!( @@ -130,6 +135,259 @@ async fn prompt(handle: &SessionHandle, run: &str, text: &str) -> TurnReport { .expect("prompt") } +#[tokio::test] +async fn session_prompt_without_operator_route_cancels_form_and_completes() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(launch(root.path(), "ISSUE-612-NO-ROUTE", "none").await) + .await + .expect("open"); + assert!( + prompt(&handle, "run-no-route", "form-no-route") + .await + .succeeded() + ); + retire(&handle).await; +} + +#[tokio::test] +async fn automatic_permission_policy_is_fenced_by_prompt_epoch() { + for routed in [false, true] { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "PERMISSION-EPOCH", "none").await; + request.profile.permissions.mode = AcpPermissionPolicy::AllowOnce; + let handle = host.open(request).await.expect("open"); + let report = if routed { + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let report = handle + .prompt_with_operator( + "policy-route".into(), + 1, + "permission-epoch".into(), + CancellationToken::new(), + Some(tx), + ) + .await + .expect("prompt"); + assert!(rx.try_recv().is_err(), "automatic decisions must not route"); + report + } else { + prompt(&handle, "policy-direct", "permission-epoch").await + }; + assert!(report.succeeded()); + let marker = root.path().join("PERMISSION-EPOCH/permission-epoch.json"); + tokio::time::timeout(Duration::from_secs(3), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("peer observed both permission decisions"); + let decisions: serde_json::Value = + serde_json::from_slice(&std::fs::read(marker).expect("marker")).expect("decisions"); + assert_eq!( + decisions["active"], + serde_json::json!({"outcome":{"outcome":"selected","optionId":"allow-opaque"}}) + ); + assert_eq!( + decisions["late"], + serde_json::json!({"outcome":{"outcome":"cancelled"}}) + ); + retire(&handle).await; + } +} + +#[tokio::test] +async fn saturated_operator_event_channel_delivers_callback_closures() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "CLOSE-SATURATION", "none").await; + request.limits.callback_timeout = Duration::from_millis(100); + request.limits.prompt_timeout = Duration::from_secs(8); + let handle = host.open(request).await.expect("open"); + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let prompt = tokio::spawn({ + let handle = handle.clone(); + async move { + handle + .prompt_with_operator( + "close-saturation".into(), + 1, + "operator-close-saturation".into(), + CancellationToken::new(), + Some(tx), + ) + .await + .expect("prompt") + } + }); + let workspace = root.path().join("CLOSE-SATURATION"); + tokio::time::timeout(Duration::from_secs(5), async { + while !workspace.join("operator-close-timed-out").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("peer observed both timeout cancellations"); + assert!( + !prompt.is_finished(), + "prompt remains active during cleanup" + ); + assert_eq!(rx.len(), 2, "both Opened events saturate the channel"); + let mut opened = BTreeSet::new(); + let mut closed = BTreeSet::new(); + for _ in 0..4 { + let event = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("every callback closure must arrive") + .expect("operator event"); + match event { + AcpOperatorEvent::Opened(request) => { + opened.insert(request.interaction.request_id); + } + AcpOperatorEvent::Closed(request_id) => { + closed.insert(request_id); + } + } + } + assert_eq!(opened.len(), 2); + assert_eq!(closed, opened); + std::fs::write(workspace.join("release-operator-close-prompt"), b"").expect("release peer"); + assert!(prompt.await.expect("prompt task").succeeded()); + retire(&handle).await; +} + +#[tokio::test] +async fn native_peer_operator_permission_and_form_question_round_trip() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let handle = host + .open(launch(root.path(), "ISSUE-612", "none").await) + .await + .expect("open"); + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let prompt = tokio::spawn({ + let handle = handle.clone(); + async move { + handle + .prompt_with_operator( + "run-612".into(), + 1, + "operator-roundtrip".into(), + CancellationToken::new(), + Some(tx), + ) + .await + .expect("prompt") + } + }); + let mut observed = Vec::new(); + while observed.len() < 2 { + let event = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("operator request timeout") + .expect("operator request"); + let AcpOperatorEvent::Opened(request) = event else { + continue; + }; + assert!(uuid::Uuid::parse_str(&request.interaction.rpc_id).is_ok()); + assert!(!request.interaction.rpc_id.contains("9007199254740993")); + observed.push(request.interaction.kind); + let answer = match request.interaction.kind { + OperatorInteractionKind::Permission => OperatorAnswer::Permission { + option_id: "allow-opaque".into(), + }, + OperatorInteractionKind::Question => OperatorAnswer::Question { + answers: vec![OperatorQuestionAnswer { + question_id: "region".into(), + selected_option_ids: vec!["west".into()], + }], + }, + OperatorInteractionKind::PlanApproval => panic!("vendor plan callback is not enabled"), + }; + let (acknowledgement, delivered) = tokio::sync::oneshot::channel(); + assert!( + request + .reply + .send(AcpOperatorReply { + answer, + acknowledgement, + delivery: AcpOperatorDeliveryFence::default(), + }) + .is_ok() + ); + assert!( + tokio::time::timeout(Duration::from_secs(5), delivered) + .await + .expect("callback acknowledgement") + .expect("acknowledgement") + ); + } + let report = tokio::time::timeout(Duration::from_secs(5), prompt) + .await + .expect("native prompt timeout") + .expect("task"); + assert!(report.succeeded()); + assert!( + run_capability(&handle.inspect().await.expect("run capability").state).operator_responses + ); + assert_eq!( + observed, + vec![ + OperatorInteractionKind::Permission, + OperatorInteractionKind::Question + ] + ); + for (run, text, answer) in [ + ("run-decline", "operator-decline", OperatorAnswer::Decline), + ("run-cancel", "operator-cancel", OperatorAnswer::Cancel), + ] { + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let request = tokio::spawn({ + let handle = handle.clone(); + async move { + handle + .prompt_with_operator( + run.into(), + 1, + text.into(), + CancellationToken::new(), + Some(tx), + ) + .await + .expect("form outcome prompt") + } + }); + let event = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("form outcome timeout") + .expect("form outcome event"); + let AcpOperatorEvent::Opened(interaction) = event else { + panic!("expected form request"); + }; + assert_eq!( + interaction.interaction.kind, + OperatorInteractionKind::Question + ); + let (acknowledgement, delivered) = tokio::sync::oneshot::channel(); + assert!( + interaction + .reply + .send(AcpOperatorReply { + answer, + acknowledgement, + delivery: AcpOperatorDeliveryFence::default(), + }) + .is_ok() + ); + assert!(delivered.await.expect("form acknowledgement")); + assert!(request.await.expect("form task").succeeded()); + } + retire(&handle).await; +} + #[tokio::test] async fn future_stop_reason_is_durable_terminal_evidence() { let root = tempfile::tempdir().expect("temp"); diff --git a/tests/action_handler.rs b/tests/action_handler.rs index 92caae34c..ca1537117 100644 --- a/tests/action_handler.rs +++ b/tests/action_handler.rs @@ -45,6 +45,7 @@ fn fixture_snapshot( total_cost_micros: 0, }, issues: vec![IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: identifier.to_owned(), title: "Test Issue".to_owned(), diff --git a/tests/fixtures/acp_peer.py b/tests/fixtures/acp_peer.py index 8faba8d85..d675cf8b3 100644 --- a/tests/fixtures/acp_peer.py +++ b/tests/fixtures/acp_peer.py @@ -69,6 +69,12 @@ def update(text): elif method == "session/prompt": assert message["params"]["sessionId"] == session prompt_id = message["id"] + if mode == "form_no_route": + send({"id": "form-no-route", "method": "elicitation/create", "params": { + "sessionId": session, "mode": "form", "message": "Choose region", + "requestedSchema": {"type": "object", "required": ["region"], "properties": { + "region": {"type": "string", "enum": ["west", "east"]}}}}}) + continue if mode == "adjacent_session_update": respond(message, {"stopReason": "end_turn"}) continue @@ -166,6 +172,10 @@ def update(text): update("before cancellation response") send({"id": prompt_id, "result": {"stopReason": "cancelled"}}) elif method is None: + if mode == "form_no_route" and message["id"] == "form-no-route": + assert message["result"] == {"action": "cancel"} + send({"id": prompt_id, "result": {"stopReason": "end_turn"}}) + continue if mode == "paced_queue": assert message["error"]["code"] == -32601 index = int(message["id"].removeprefix("queued-")) + 1 diff --git a/tests/fixtures/acp_services_peer.py b/tests/fixtures/acp_services_peer.py index 9d6b2686b..c0a94856b 100644 --- a/tests/fixtures/acp_services_peer.py +++ b/tests/fixtures/acp_services_peer.py @@ -62,7 +62,7 @@ def config(value='first'): assert caps.get('fs', {}).get('readTextFile', False) == enabled, caps assert caps.get('fs', {}).get('writeTextFile', False) == enabled, caps assert not caps.get('auth', {}).get('terminal') - assert not caps.get('elicitation') + assert caps.get('elicitation') == {'form': {}} respond(message, {'protocolVersion': 1, 'agentCapabilities': {'mcpCapabilities': {'http': mode == 'mcp'}}}) elif method == 'session/new': assert message['params']['cwd'] == os.getcwd() diff --git a/tests/fixtures/acp_session_peer.py b/tests/fixtures/acp_session_peer.py index 29621bb72..ee4973e4b 100644 --- a/tests/fixtures/acp_session_peer.py +++ b/tests/fixtures/acp_session_peer.py @@ -59,7 +59,7 @@ def callback(method, params, error=False): response = json.loads(sys.stdin.readline()) assert response['id'] == serial, response assert ('error' in response) == error, response - return response.get('result') + return response.get('error') if error else response.get('result') with open("launches", "a") as log: @@ -79,6 +79,7 @@ def callback(method, params, error=False): with open("methods", "a") as log: log.write(method + "\n") if method == "initialize": + assert message['params']['clientCapabilities']['elicitation'] == {'form': {}} caps = {"loadSession": persistence == "load"} if persistence == "resume": caps["sessionCapabilities"] = {"resume": {}} @@ -138,6 +139,70 @@ def callback(method, params, error=False): with open(".opensymphony/conversation.json") as file: assert json.load(file)["acp"]["status"] == "submitted" text = message["params"]["prompt"][0]["text"] + if text == 'form-no-route': + result = callback('elicitation/create', {'mode': 'form', 'message': 'Choose region', + 'requestedSchema': {'type': 'object', 'required': ['region'], 'properties': { + 'region': {'type': 'string', 'enum': ['east', 'west']}}}}) + assert result == {'action': 'cancel'}, result + send({'id': message['id'], 'result': {'stopReason': 'end_turn'}}) + continue + if text == 'permission-epoch': + params = {'toolCall': {'toolCallId': 'policy-tool', 'title': 'Run tests'}, + 'options': [{'optionId': 'allow-opaque', 'name': 'Allow once', 'kind': 'allow_once'}, + {'optionId': 'deny-opaque', 'name': 'Deny once', 'kind': 'reject_once'}]} + active = callback('session/request_permission', params) + send({'id': message['id'], 'result': {'stopReason': 'end_turn'}}) + late = callback('session/request_permission', params) + with open('permission-epoch.json.tmp', 'w') as result_file: + json.dump({'active': active, 'late': late}, result_file) + os.replace('permission-epoch.json.tmp', 'permission-epoch.json') + continue + if text == 'operator-close-saturation': + request_ids = (201, 202) + for request_id in request_ids: + send({'id': request_id, 'method': 'session/request_permission', 'params': { + 'sessionId': session, + 'toolCall': {'toolCallId': f'tool-{request_id}', 'title': 'Run tests'}, + 'options': [{'optionId': 'allow', 'name': 'Allow once', 'kind': 'allow_once'}, + {'optionId': 'deny', 'name': 'Deny once', 'kind': 'reject_once'}]}}) + responses = [json.loads(sys.stdin.readline()) for _ in request_ids] + assert {response['id'] for response in responses} == set(request_ids), responses + assert all(response['result'] == {'outcome': {'outcome': 'cancelled'}} + for response in responses), responses + open('operator-close-timed-out', 'w').close() + import time + while not os.path.exists('release-operator-close-prompt'): + time.sleep(0.01) + send({'id': message['id'], 'result': {'stopReason': 'end_turn'}}) + continue + if text == 'operator-roundtrip': + serial = 9007199254740992 + assert callback('cursor/ask_question', {'questions': []}, error=True)['code'] == -32601 + assert callback('cursor/create_plan', {'plan': 'Ship'}, error=True)['code'] == -32601 + rejected_form = callback('elicitation/create', {'mode': 'form', 'message': 'Enter API key', + 'requestedSchema': {'type': 'object', 'required': ['key'], 'properties': { + 'key': {'type': 'string', 'enum': ['example']}}}}) + assert rejected_form == {'action': 'cancel'}, rejected_form + rejected_url = callback('elicitation/create', {'mode': 'url', 'message': 'Open sign-in', + 'elicitationId': 'login', 'url': 'https://example.com/sign-in'}) + assert rejected_url == {'action': 'cancel'}, rejected_url + permission = callback('session/request_permission', {'toolCall': {'toolCallId': 'tool-1', 'title': 'Run tests'}, + 'options': [{'optionId': 'allow-opaque', 'name': 'Allow once', 'kind': 'allow_once'}, + {'optionId': 'deny-opaque', 'name': 'Deny once', 'kind': 'reject_once'}]}) + assert permission == {'outcome': {'outcome': 'selected', 'optionId': 'allow-opaque'}}, permission + question = callback('elicitation/create', {'mode': 'form', 'message': 'Choose region', 'requestedSchema': { + 'type': 'object', 'required': ['region'], 'properties': {'region': {'type': 'string', + 'title': 'Region?', 'oneOf': [{'const': 'east', 'title': 'East'}, {'const': 'west', 'title': 'West'}]}}}}) + assert question == {'action': 'accept', 'content': {'region': 'west'}}, question + send({'id': message['id'], 'result': {'stopReason': 'end_turn'}}) + continue + if text in ('operator-decline', 'operator-cancel'): + question = callback('elicitation/create', {'mode': 'form', 'message': 'Choose region', 'requestedSchema': { + 'type': 'object', 'required': ['region'], 'properties': {'region': {'type': 'string', + 'enum': ['east', 'west']}}}}) + assert question == {'action': 'decline' if text == 'operator-decline' else 'cancel'}, question + send({'id': message['id'], 'result': {'stopReason': 'end_turn'}}) + continue if text == 'file-privacy': content = callback('fs/read_text_file', {'path': os.path.join(os.getcwd(), 'private-file')})['content'] assert content == 'opaque_workspace_payload_610' diff --git a/tests/fixtures/acp_worker_peer.py b/tests/fixtures/acp_worker_peer.py index d2997697d..8a201fe2b 100644 --- a/tests/fixtures/acp_worker_peer.py +++ b/tests/fixtures/acp_worker_peer.py @@ -25,6 +25,7 @@ def request(method, params): message = json.loads(line) method = message.get("method") if method == "initialize": + assert message["params"]["clientCapabilities"]["elicitation"] == {"form": {}} if profile == "setup_retry" and not os.path.exists(".opensymphony/setup-failed"): open(".opensymphony/setup-failed", "w").close() sys.exit(8) @@ -102,6 +103,24 @@ def request(method, params): pending = message["id"] send({"id": "permission", "method": "session/request_permission", "params": {"sessionId": session, "toolCall": {"toolCallId": "t", "title": "Permission"}, "options": []}}) continue + if profile == "operator_roundtrip": + assert request("session/request_permission", {"toolCall":{"toolCallId":"tool-1","title":"Run tests"}, + "options":[{"optionId":"allow-opaque","name":"Allow once","kind":"allow_once"}, + {"optionId":"deny-opaque","name":"Deny once","kind":"reject_once"}]}) == \ + {"outcome":{"outcome":"selected","optionId":"allow-opaque"}} + assert request("elicitation/create", {"mode":"form","message":"Choose region","requestedSchema":{ + "type":"object","required":["region"],"properties":{"region":{"type":"string", + "title":"Region?","oneOf":[{"const":"west","title":"West"}]}}}}) == \ + {"action":"accept","content":{"region":"west"}} + with open("acp-operator-roundtrip.json.tmp", "w") as output: + json.dump({"permission":"allow-opaque","question":"west"}, output) + os.replace("acp-operator-roundtrip.json.tmp", "acp-operator-roundtrip.json") + if profile == "operator_early_finish": + send({"id":"early-question","method":"elicitation/create","params":{"sessionId":session, + "mode":"form","message":"Choose region","requestedSchema":{"type":"object", + "required":["region"],"properties":{"region":{"type":"string","enum":["west"]}}}}}) + send({"id":message["id"],"result":{"stopReason":"end_turn"}}) + continue if profile in ("hang", "slow_model_hang"): pending = message["id"] continue @@ -114,6 +133,8 @@ def request(method, params): elif method is None and message.get("id") == "permission": assert message["result"]["outcome"]["outcome"] == "cancelled" send({"id": pending, "result": {"stopReason": "cancelled"}}) + elif method is None and message.get("id") == "early-question": + assert message["result"]["action"] == "cancel" elif method == "session/cancel": send({"id": pending, "result": {"stopReason": "cancelled"}}) else: diff --git a/tests/task_graph_mutations_routes.rs b/tests/task_graph_mutations_routes.rs index 07c6344c1..8ca70154b 100644 --- a/tests/task_graph_mutations_routes.rs +++ b/tests/task_graph_mutations_routes.rs @@ -73,6 +73,7 @@ fn fixture_snapshot(step: u64) -> DaemonSnapshot { total_cost_micros: 120_000, }, issues: vec![IssueSnapshot { + operator_interactions: Vec::new(), harness_capability: None, identifier: "COE-405".to_owned(), title: "Linear Milestone, Issue, And Sub-Issue Mutations".to_owned(), From 742873adcc1467fee3318a800e4dfb33675fcf67 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Wed, 23 Sep 2026 14:45:58 -0500 Subject: [PATCH 20/29] docs(memory): sync COE-612 operator routing evidence Summary: - Capture merged COE-612 with verified PR #246 and merge SHA. - Sync all 178 issue capsules into managed topic docs. Rationale: - Keep the full project memory corpus current after the ACP merge. - Preserve existing managed history while adding COE-612 refs. Tests: - opensymphony memory lint --public-docs - opensymphony memory status: 178 captured, 0 pending, 0 warnings - git diff --cached --check --- .opensymphony/memory/memory.yaml | 57 ++++++++++++++++++++++++++++++++ docs/accessibility.md | 2 ++ docs/acp.md | 3 ++ docs/architecture.md | 2 ++ docs/build.md | 2 ++ docs/cli.md | 2 ++ docs/code-intelligence.md | 2 ++ docs/code-review.md | 2 ++ docs/codex-runtime.md | 2 ++ docs/codex.md | 2 ++ docs/configuration.md | 2 ++ docs/control.md | 2 ++ docs/data-contracts.md | 2 ++ docs/debugging.md | 2 ++ docs/desktop.md | 2 ++ docs/developer-experience.md | 2 ++ docs/docs.md | 2 ++ docs/documentation.md | 2 ++ docs/duckdb.md | 2 ++ docs/evidence.md | 2 ++ docs/experiments.md | 2 ++ docs/frontend.md | 2 ++ docs/gateway.md | 2 ++ docs/graph-view.md | 2 ++ docs/harness-runtime.md | 2 ++ docs/harness.md | 2 ++ docs/installer.md | 2 ++ docs/linear.md | 2 ++ docs/mcp.md | 2 ++ docs/memory.md | 2 ++ docs/migration.md | 2 ++ docs/model-configuration.md | 2 ++ docs/okf.md | 2 ++ docs/openhands-runtime.md | 2 ++ docs/operations.md | 2 ++ docs/orchestrator.md | 2 ++ docs/outcomes.md | 2 ++ docs/planning.md | 2 ++ docs/process-protocol.md | 2 ++ docs/provenance.md | 2 ++ docs/provider.md | 2 ++ docs/quality.md | 2 ++ docs/query-packs.md | 2 ++ docs/recovery.md | 3 ++ docs/release.md | 2 ++ docs/review.md | 2 ++ docs/run-detail.md | 2 ++ docs/runner.md | 2 ++ docs/runtime.md | 2 ++ docs/rust.md | 2 ++ docs/scheduler.md | 2 ++ docs/security.md | 2 ++ docs/sources.md | 2 ++ docs/target-repo-bootstrap.md | 2 ++ docs/task-packets.md | 2 ++ docs/template.md | 2 ++ docs/testing-and-operations.md | 2 ++ docs/testing.md | 2 ++ docs/tests.md | 2 ++ docs/threejs.md | 2 ++ docs/tui.md | 2 ++ docs/ui.md | 2 ++ docs/verifiers.md | 2 ++ docs/web.md | 2 ++ docs/workflow.md | 2 ++ docs/workspace-and-lifecycle.md | 2 ++ docs/workspace-lifecycle.md | 2 ++ docs/workspace.md | 2 ++ 68 files changed, 193 insertions(+) diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index 720ef62bc..941745b1e 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -76,6 +76,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -106,8 +107,10 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:orchestrator - area:runtime + - area:ui - area:workspace linear_milestones: - 'M12.99: ACP Harness Runtime Adapter' @@ -405,6 +408,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -481,6 +485,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -673,6 +678,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -751,6 +757,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -887,6 +894,7 @@ areas: - area:data-contracts - area:evidence - area:experiments + - area:gateway - area:harness - area:memory - area:orchestrator @@ -902,6 +910,7 @@ areas: - area:scheduler - area:task-packets - area:testing + - area:ui - area:verifiers - area:workspace - area:workspace-lifecycle @@ -968,6 +977,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -1373,6 +1383,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -1509,6 +1520,7 @@ areas: - area:configuration - area:evidence - area:experiments + - area:gateway - area:harness - area:memory - area:orchestrator @@ -1524,6 +1536,7 @@ areas: - area:scheduler - area:task-packets - area:testing + - area:ui - area:verifiers - area:workspace - area:workspace-lifecycle @@ -1546,6 +1559,7 @@ areas: - area:cli - area:configuration - area:experiments + - area:gateway - area:harness - area:memory - area:orchestrator @@ -1555,6 +1569,7 @@ areas: - area:runtime - area:scheduler - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -1619,6 +1634,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -1787,6 +1803,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -1817,6 +1834,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -1825,6 +1843,7 @@ areas: - area:review - area:runtime - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -2450,6 +2469,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -2748,6 +2768,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -2971,6 +2992,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -2980,6 +3002,7 @@ areas: - area:review - area:runtime - area:testing + - area:ui - area:verifiers - area:workspace - area:workspace-lifecycle @@ -3045,6 +3068,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -3093,6 +3117,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -3103,6 +3128,7 @@ areas: - area:runner - area:runtime - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3122,6 +3148,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -3132,6 +3159,7 @@ areas: - area:runtime - area:task-packets - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3151,11 +3179,13 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:orchestrator - area:provider - area:recovery - area:review - area:runtime + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3176,6 +3206,7 @@ areas: - area:configuration - area:evidence - area:experiments + - area:gateway - area:harness - area:memory - area:orchestrator @@ -3191,6 +3222,7 @@ areas: - area:scheduler - area:task-packets - area:testing + - area:ui - area:verifiers - area:workspace - area:workspace-lifecycle @@ -3258,6 +3290,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -3288,9 +3321,11 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:orchestrator - area:recovery - area:runtime + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3354,6 +3389,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -3404,10 +3440,12 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:orchestrator - area:recovery - area:review - area:runtime + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3472,6 +3510,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -3502,6 +3541,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -3511,6 +3551,7 @@ areas: - area:runner - area:runtime - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3667,6 +3708,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -3698,6 +3740,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -3707,6 +3750,7 @@ areas: - area:runtime - area:scheduler - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -3899,6 +3943,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -3930,6 +3975,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -3939,6 +3985,7 @@ areas: - area:runtime - area:task-packets - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -4003,6 +4050,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -4203,6 +4251,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -4279,6 +4328,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -4506,6 +4556,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -4514,6 +4565,7 @@ areas: - area:review - area:runtime - area:testing + - area:ui - area:verifiers - area:workspace - area:workspace-lifecycle @@ -4579,6 +4631,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -4669,6 +4722,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow @@ -4700,6 +4754,7 @@ areas: - area:acp - area:cli - area:configuration + - area:gateway - area:harness - area:memory - area:orchestrator @@ -4708,6 +4763,7 @@ areas: - area:review - area:runtime - area:testing + - area:ui - area:workspace - area:workspace-lifecycle linear_milestones: @@ -4788,6 +4844,7 @@ areas: - area:testing - area:tests - area:threejs + - area:ui - area:verifiers - area:web - area:workflow diff --git a/docs/accessibility.md b/docs/accessibility.md index 007dcb677..a1ecbfb96 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/acp.md b/docs/acp.md index fb38b7b6a..23582dd18 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -15,6 +15,7 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) - COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) - COE-611 contributed: PR #245: COE-611: Route production workers through ACP profiles (merge `80e5ac3`) +- COE-612 contributed: PR #246: Route ACP operator requests through scheduler and clients (merge `a6cd118`) ## Important invariants @@ -35,6 +36,7 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -42,5 +44,6 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/architecture.md b/docs/architecture.md index 918e46762..33cb38438 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -706,6 +706,7 @@ OpenHands server or client participates in this launch path. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -845,5 +846,6 @@ OpenHands server or client participates in this launch path. - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/build.md b/docs/build.md index 9e5481fc4..1072c7c5f 100644 --- a/docs/build.md +++ b/docs/build.md @@ -134,6 +134,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -238,5 +239,6 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/cli.md b/docs/cli.md index 132e991e3..88d3af0d2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -131,6 +131,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -232,5 +233,6 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index 9abc9c1cc..dac7c3f2e 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -287,6 +287,7 @@ references, and never returns source snippets. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -350,5 +351,6 @@ references, and never returns source snippets. - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/code-review.md b/docs/code-review.md index d4027371f..95ffdea91 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -158,5 +159,6 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index 5975e0995..c44120e33 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -158,5 +159,6 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/codex.md b/docs/codex.md index 98d6e7443..c699e0b6b 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -236,5 +237,6 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/configuration.md b/docs/configuration.md index 9402acc21..982f3529e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1266,6 +1266,7 @@ dependent options. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -1427,5 +1428,6 @@ dependent options. - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/control.md b/docs/control.md index 69a04c4e5..e092bebc5 100644 --- a/docs/control.md +++ b/docs/control.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -236,5 +237,6 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index b1f5f49d4..6e880772c 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -47,6 +47,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -64,5 +65,6 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/debugging.md b/docs/debugging.md index db53e29c4..87acef6fc 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/desktop.md b/docs/desktop.md index 965b34591..110acb05d 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -134,6 +134,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -240,6 +241,7 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 - PR-196 - PR-197 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index 0f8c3e1eb..b3856aae4 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/docs.md b/docs/docs.md index c7998f340..58644c44b 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -134,6 +134,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing - PR-197: Graph visualization command center and shared fixtures ## Source refs @@ -239,6 +240,7 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 - PR-197 diff --git a/docs/documentation.md b/docs/documentation.md index b9765a9f2..d7e5a0022 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -95,6 +95,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -160,5 +161,6 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/duckdb.md b/docs/duckdb.md index 6ad63e362..835245395 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/evidence.md b/docs/evidence.md index fec35816a..f05446d4c 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -47,6 +47,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -64,5 +65,6 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/experiments.md b/docs/experiments.md index 21486639a..5079956c3 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/frontend.md b/docs/frontend.md index 49d1c689c..870daa565 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -160,6 +161,7 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 - PR-196 - PR-197 diff --git a/docs/gateway.md b/docs/gateway.md index 6c92b9c63..92e4ea7e2 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -155,6 +155,7 @@ baseline, not an issue-workspace overlay. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing - PR-196: Desktop task and diff latency plus live-refresh UX fixes ## Source refs @@ -260,6 +261,7 @@ baseline, not an issue-workspace overlay. - COE-609 - COE-610 - COE-611 +- COE-612 - PR-196 diff --git a/docs/graph-view.md b/docs/graph-view.md index 27db2ad27..ada658d2f 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -158,6 +159,7 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 - PR-196 - PR-197 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index f0c74ebf0..94f3a7ee7 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/harness.md b/docs/harness.md index 956c9c0b4..85e39c4c5 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -41,6 +41,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -52,5 +53,6 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/installer.md b/docs/installer.md index 37beb8891..e8eb09585 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/linear.md b/docs/linear.md index 4ceddf916..a614e5fae 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -134,6 +134,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -238,5 +239,6 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/mcp.md b/docs/mcp.md index 631e79470..873a4d4ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/memory.md b/docs/memory.md index 970ab41c7..66105a8c4 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -847,6 +847,7 @@ are covered by the same archive operation. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -1016,5 +1017,6 @@ are covered by the same archive operation. - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/migration.md b/docs/migration.md index 8240b3f8c..0ad60d6fa 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index 2e567db65..f56b28cdb 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/okf.md b/docs/okf.md index 7ea90fa38..9f125a8dc 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index eb6b2541c..d2cf3cd38 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -94,6 +94,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -158,5 +159,6 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/operations.md b/docs/operations.md index efe7a5e8a..810ac0e13 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1277,6 +1277,7 @@ while the same request remains pending. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -1449,5 +1450,6 @@ while the same request remains pending. - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index ecd2f7dae..77dfabefa 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -236,5 +237,6 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/outcomes.md b/docs/outcomes.md index 86a58279f..334cc362c 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/planning.md b/docs/planning.md index 8e7dd0cf3..a150bf0d9 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -92,6 +92,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -154,5 +155,6 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index 5e29b6aaf..f1075bf50 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/provenance.md b/docs/provenance.md index b104ae35f..bffa46b96 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/provider.md b/docs/provider.md index 704b40025..e94475fc9 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/quality.md b/docs/quality.md index ff3851176..18d8cf848 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -47,6 +47,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -64,5 +65,6 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/query-packs.md b/docs/query-packs.md index b2ee624ac..c9c1237f6 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/recovery.md b/docs/recovery.md index 192e53bf1..fcf3ac7b5 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-609 contributed: PR #243: feat(acp): retain host-owned sessions with durable recovery (merge `5b67134`) - COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) - COE-611 contributed: PR #245: COE-611: Route production workers through ACP profiles (merge `80e5ac3`) +- COE-612 contributed: PR #246: Route ACP operator requests through scheduler and clients (merge `a6cd118`) ## Important invariants @@ -37,6 +38,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -45,5 +47,6 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/release.md b/docs/release.md index f7c2299cc..965af4ed5 100644 --- a/docs/release.md +++ b/docs/release.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -152,6 +153,7 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 ## 2.11 Rust toolchain boundary diff --git a/docs/review.md b/docs/review.md index b0ea00d48..b5b7f1066 100644 --- a/docs/review.md +++ b/docs/review.md @@ -39,6 +39,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -48,5 +49,6 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/run-detail.md b/docs/run-detail.md index b075989ff..0b6d03207 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -91,6 +91,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -152,5 +153,6 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/runner.md b/docs/runner.md index d646935bf..f08821dd6 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/runtime.md b/docs/runtime.md index bc975456e..e861d1e29 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -133,6 +133,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -236,5 +237,6 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/rust.md b/docs/rust.md index a58e8d1b2..2b2fed59e 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -95,6 +95,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -160,5 +161,6 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/scheduler.md b/docs/scheduler.md index 76a99357a..65f06a5ab 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/security.md b/docs/security.md index a21bdf046..214f05376 100644 --- a/docs/security.md +++ b/docs/security.md @@ -134,6 +134,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -238,5 +239,6 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/sources.md b/docs/sources.md index 92957b023..627cd0d99 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -369,6 +369,7 @@ write/delete sharing until file I/O or terminal spawn finishes. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -454,5 +455,6 @@ write/delete sharing until file I/O or terminal spawn finishes. - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index c12337101..d59f8899d 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/task-packets.md b/docs/task-packets.md index 79b5f1174..47f191e19 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -43,6 +43,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -56,5 +57,6 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/template.md b/docs/template.md index 06ece9d7e..80773c957 100644 --- a/docs/template.md +++ b/docs/template.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 37698169e..1071e53c1 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -947,6 +947,7 @@ tracker suites so local OAuth configuration does not override fixture credential - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -1102,5 +1103,6 @@ tracker suites so local OAuth configuration does not override fixture credential - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/testing.md b/docs/testing.md index c0063d055..436b12a9e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -132,6 +132,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -236,6 +237,7 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 - PR-196 - PR-197 diff --git a/docs/tests.md b/docs/tests.md index 209a24293..c74d3d666 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -95,6 +95,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -160,5 +161,6 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/threejs.md b/docs/threejs.md index b95c0f32e..c0f7d2c8c 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -93,6 +93,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -156,5 +157,6 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/tui.md b/docs/tui.md index 8f4281709..0445749a5 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -228,5 +229,6 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/ui.md b/docs/ui.md index 7ca72046c..fe0e264a4 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -129,6 +129,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -230,6 +231,7 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 - PR-196 - PR-197 diff --git a/docs/verifiers.md b/docs/verifiers.md index 92148b51b..442b134c6 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/web.md b/docs/web.md index 8705e687a..9dec0517a 100644 --- a/docs/web.md +++ b/docs/web.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/workflow.md b/docs/workflow.md index 1b426f4ad..140309728 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -95,6 +95,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -160,5 +161,6 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 341d2aec5..41f5106a0 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -1103,6 +1103,7 @@ or MCP grants. - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -1126,5 +1127,6 @@ or MCP grants. - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index 750e53ce3..bb8936394 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -90,6 +90,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -150,5 +151,6 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 diff --git a/docs/workspace.md b/docs/workspace.md index 521757549..b18245a81 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -42,6 +42,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-609: ACP Session Ownership And Durable Recovery - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration +- COE-612: ACP Operator Requests And Response Routing ## Source refs @@ -54,5 +55,6 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-609 - COE-610 - COE-611 +- COE-612 From 90154b578822e5af122545b3b79f38e2220670ae Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Fri, 25 Sep 2026 00:15:45 -0500 Subject: [PATCH 21/29] COE-613: Add registered ACP extensions and harness operations (#247) * feat(acp): route registered extensions and harness operations Summary: - Handle pinned Cursor question, plan, and notification callbacks through bounded ACP dispatch and scheduler-owned operator responses. - Advertise peer-gated operation schemas and route a registered outbound operation through gateway authorization and run binding. - Add fake-peer, gateway, schema, and timeout tests plus a redacted Cursor CLI qualification record. Rationale: - Custom ACP methods need explicit version and effect policies; arbitrary wire methods and session IDs cannot be operator inputs. - The real pinned Cursor CLI requires login before vendor callbacks can be qualified, so the live-evidence gap remains documented. Tests: - cargo fmt --all -- --check - cargo clippy-system-duckdb - cargo test-system-duckdb --lib opensymphony_acp:: (49 passed) - cargo test-system-duckdb --test acp --test acp_session_host --test gateway --test gateway_schema (220 passed) - npm run type-check - npx jest --config jest.config.js packages/gateway-schema/__tests__ --runInBand (66 passed) * fix(acp): bound extension operations and gate Cursor qualification * fix(acp): bound unresolved operations and redact results * feat(acp): qualify pinned Cursor plan and todo callbacks * fix(acp): fence saturated and ambiguous Cursor todo projection * fix(acp): correlate Cursor todos across callback methods --- crates/opensymphony-acp/src/durable.rs | 13 + crates/opensymphony-acp/src/extensions.rs | 207 +++++++ crates/opensymphony-acp/src/host.rs | 102 ++++ crates/opensymphony-acp/src/lib.rs | 52 +- crates/opensymphony-acp/src/operator.rs | 270 ++++++++- crates/opensymphony-acp/src/projection.rs | 376 +++++++++++- .../src/orchestrator_run/backends.rs | 45 +- .../src/orchestrator_run/backends/acp.rs | 72 ++- .../src/orchestrator_run/config.rs | 2 +- .../src/orchestrator_run/mod.rs | 82 ++- .../opensymphony-gateway-schema/src/action.rs | 7 + .../src/capability.rs | 21 + .../tests/gateway_schema.rs | 17 + .../src/action_handler.rs | 1 + crates/opensymphony-gateway/src/lib.rs | 226 ++++++- crates/opensymphony-gateway/tests/gateway.rs | 196 +++++- crates/opensymphony-orchestrator/src/lib.rs | 9 +- .../src/scheduler.rs | 62 ++ crates/opensymphony-tui/src/lib.rs | 26 +- crates/opensymphony-workflow/src/acp.rs | 15 +- crates/opensymphony-workspace/src/models.rs | 3 + docs/acp-extension-evidence.md | 77 +++ docs/architecture.md | 22 + docs/configuration.md | 24 +- docs/harness-adapter-compatibility.md | 3 +- docs/operations.md | 31 +- docs/specs/acp-harness-adapter.md | 23 +- docs/testing-and-operations.md | 19 + docs/workspace-and-lifecycle.md | 4 + .../__tests__/capability.test.ts | 3 + .../__tests__/fixtures/acp-capability.json | 73 ++- packages/gateway-schema/src/action.ts | 1 + packages/gateway-schema/src/capability.ts | 14 + packages/gateway-schema/src/index.ts | 2 +- tests/acp.rs | 22 + tests/acp_session_host.rs | 571 +++++++++++++++++- tests/fixtures/acp_session_peer.py | 53 ++ 37 files changed, 2650 insertions(+), 96 deletions(-) create mode 100644 crates/opensymphony-acp/src/extensions.rs create mode 100644 docs/acp-extension-evidence.md diff --git a/crates/opensymphony-acp/src/durable.rs b/crates/opensymphony-acp/src/durable.rs index 85bd89db1..08c21e904 100644 --- a/crates/opensymphony-acp/src/durable.rs +++ b/crates/opensymphony-acp/src/durable.rs @@ -199,6 +199,7 @@ impl Durability { }, session_id: None, initialization: Value::Null, + enabled_operations: Vec::new(), model_selection: false, status: AcpSessionStatus::Ready, stop_reason: None, @@ -403,6 +404,9 @@ impl Durability { &mut self, session_id: String, initialization: Value, + enabled_operations: Vec< + crate::opensymphony_gateway_schema::capability::HarnessOperationCapability, + >, model_selection: bool, recovery: AcpRecovery, reset_reason: Option, @@ -418,6 +422,7 @@ impl Durability { let state = self.state_mut(); state.session_id = Some(session_id); state.initialization = initialization; + state.enabled_operations = enabled_operations; state.model_selection = model_selection; state.recovery = recovery; if recovery == AcpRecovery::Fresh { @@ -715,6 +720,7 @@ mod tests { .ready( "session-1".into(), Value::Null, + Vec::new(), false, AcpRecovery::Fresh, None, @@ -768,6 +774,7 @@ mod tests { .ready( "session-1".into(), Value::Null, + Vec::new(), false, AcpRecovery::Fresh, None, @@ -818,6 +825,7 @@ mod tests { .ready( "session-1".into(), Value::Null, + Vec::new(), false, AcpRecovery::Fresh, None, @@ -912,6 +920,7 @@ mod tests { .ready( "opaque/session:1".into(), json!({"protocolVersion": 1}), + Vec::new(), false, AcpRecovery::Fresh, None, @@ -937,6 +946,7 @@ mod tests { .ready( "replacement".into(), Value::Null, + Vec::new(), false, AcpRecovery::Fresh, Some("reset".into()) @@ -977,6 +987,7 @@ mod tests { .ready( "opaque/session:1".into(), json!({"protocolVersion": 1}), + Vec::new(), false, AcpRecovery::RestoredLoad, None, @@ -1119,6 +1130,7 @@ mod tests { .ready( "bad\nsession".into(), Value::Null, + Vec::new(), false, AcpRecovery::Fresh, None @@ -1131,6 +1143,7 @@ mod tests { .ready( "session".into(), json!("x".repeat(MAX_MANIFEST_BYTES)), + Vec::new(), false, AcpRecovery::Fresh, None diff --git a/crates/opensymphony-acp/src/extensions.rs b/crates/opensymphony-acp/src/extensions.rs new file mode 100644 index 000000000..dd7442c98 --- /dev/null +++ b/crates/opensymphony-acp/src/extensions.rs @@ -0,0 +1,207 @@ +//! Explicit ACP extension registrations. A profile opts into reviewed wire contracts; +//! an agent's advertised support never grants an operator permission by itself. +use serde_json::{Value, json}; + +use super::AcpProfile; +pub use crate::opensymphony_gateway_schema::capability::HarnessOperationCapability as OperationCapability; + +pub const CURSOR_VERSION: &str = "cursor@2026.09.08-6caf4ff"; +pub const FIXTURE_ECHO_VERSION: &str = "fixture_echo@1"; +pub const FIXTURE_ECHO_OPERATION: &str = "fixture.echo"; +pub const FIXTURE_ECHO_METHOD: &str = "_opensymphony.test/echo"; + +fn metadata_schema() -> Value { + json!({ + "type": "object", + "maxProperties": 8, + "propertyNames": { + "allOf": [ + {"maxLength": 128}, + {"anyOf": [{"pattern": "/"}, {"enum": ["traceparent", "tracestate", "baggage"]}]} + ] + } + }) +} + +pub fn cursor_enabled(profile: &AcpProfile) -> bool { + profile.extensions.iter().any(|id| id == CURSOR_VERSION) +} + +pub fn cursor_todos(params: &Value) -> bool { + let Some(object) = params.as_object() else { + return false; + }; + if object.len() > 16 || !serde_json::to_vec(params).is_ok_and(|bytes| bytes.len() <= 64 * 1024) + { + return false; + } + let bounded = |key: &str, max: usize| { + object + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty() && value.len() <= max) + }; + bounded("toolCallId", 128) + && object + .get("todos") + .and_then(Value::as_array) + .is_some_and(|todos| { + todos.len() <= 128 + && todos.iter().all(|todo| { + todo.get("id") + .and_then(Value::as_str) + .is_some_and(|id| !id.is_empty() && id.len() <= 128) + && todo + .get("content") + .and_then(Value::as_str) + .is_some_and(|content| content.len() <= 2048) + && todo + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| { + matches!( + status, + "pending" | "in_progress" | "completed" | "cancelled" + ) + }) + }) + }) + && object.get("merge").is_some_and(Value::is_boolean) +} + +pub fn fixture_echo_enabled(profile: &AcpProfile, initialization: &Value) -> bool { + profile + .extensions + .iter() + .any(|id| id == FIXTURE_ECHO_VERSION) + && initialization.pointer("/agentCapabilities/_meta/opensymphony.dev~1fixtureEcho") + == Some(&json!(1)) +} + +pub fn outbound_operation( + profile: &AcpProfile, + initialization: &Value, + operation_id: &str, +) -> Option { + if operation_id != FIXTURE_ECHO_OPERATION || !fixture_echo_enabled(profile, initialization) { + return None; + } + Some(OperationCapability { + operation_id: FIXTURE_ECHO_OPERATION.into(), + namespace: "opensymphony.test".into(), + version: "1".into(), + capability_predicate: "agentCapabilities._meta['opensymphony.dev/fixtureEcho'] == 1".into(), + parameters_schema: json!({"type":"object","required":["value"],"properties":{"value":{"type":"string","maxLength":4096},"_meta":metadata_schema()},"additionalProperties":false}), + result_schema: json!({"type":"object","required":["value"],"properties":{"value":{"type":"string","maxLength":4096},"_meta":metadata_schema()},"additionalProperties":false}), + deadline_ms: 5000, + effect: "read_only_idempotent".into(), + }) +} + +pub fn configured_operations(profile: &AcpProfile) -> Vec { + if !profile + .extensions + .iter() + .any(|id| id == FIXTURE_ECHO_VERSION) + { + return Vec::new(); + } + // This describes the predicate for IDE negotiation; active use also checks + // the peer's negotiated capability and the current session binding. + outbound_operation( + profile, + &json!({"agentCapabilities":{"_meta":{"opensymphony.dev/fixtureEcho":1}}}), + FIXTURE_ECHO_OPERATION, + ) + .into_iter() + .collect() +} + +pub fn validate_echo_arguments(arguments: &Value) -> bool { + let Some(object) = arguments.as_object() else { + return false; + }; + if object.len() > 2 + || !object.contains_key("value") + || object.keys().any(|key| key != "value" && key != "_meta") + { + return false; + } + if !object + .get("value") + .and_then(Value::as_str) + .is_some_and(|value| value.len() <= 4096) + { + return false; + } + object.get("_meta").is_none_or(|meta| { + meta.as_object().is_some_and(|fields| { + fields.len() <= 8 + && fields.iter().all(|(key, value)| { + (key.contains('/') + || matches!(key.as_str(), "traceparent" | "tracestate" | "baggage")) + && key.len() <= 128 + && serde_json::to_vec(value).is_ok_and(|bytes| bytes.len() <= 4096) + }) + }) + }) +} + +pub fn validate_echo_result(result: &Value) -> bool { + result.as_object().is_some_and(|object| { + object.len() <= 2 + && object + .get("value") + .and_then(Value::as_str) + .is_some_and(|value| value.len() <= 4096) + && object.keys().all(|key| key == "value" || key == "_meta") + && object.get("_meta").is_none_or(|meta| { + meta.as_object().is_some_and(|fields| { + fields.len() <= 8 + && fields.iter().all(|(key, value)| { + (key.contains('/') + || matches!(key.as_str(), "traceparent" | "tracestate" | "baggage")) + && key.len() <= 128 + && serde_json::to_vec(value).is_ok_and(|bytes| bytes.len() <= 4096) + }) + }) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn echo_requires_registered_profile_and_peer_capability() { + let mut profile: AcpProfile = + serde_json::from_value(json!({"command":"peer"})).expect("fixture profile"); + let advertised = json!({"agentCapabilities":{"_meta":{"opensymphony.dev/fixtureEcho":1}}}); + assert!(outbound_operation(&profile, &advertised, FIXTURE_ECHO_OPERATION).is_none()); + profile.extensions.push(FIXTURE_ECHO_VERSION.into()); + assert!(outbound_operation(&profile, &json!({}), FIXTURE_ECHO_OPERATION).is_none()); + let operation = outbound_operation(&profile, &advertised, FIXTURE_ECHO_OPERATION) + .expect("negotiated operation"); + assert_eq!( + operation.result_schema["properties"]["_meta"]["type"], + "object" + ); + assert_eq!( + operation.result_schema["properties"]["_meta"]["maxProperties"], + 8 + ); + assert!(validate_echo_arguments( + &json!({"value":"ok","_meta":{"traceparent":"trace"}}) + )); + assert!(!validate_echo_arguments( + &json!({"value":"ok","method":"other"}) + )); + assert!(validate_echo_result(&json!({"value":"ok"}))); + assert!(!validate_echo_result(&json!({"value":42}))); + assert!(!validate_echo_result(&json!({"value":"ok","execute":true}))); + assert!(!validate_echo_arguments( + &json!({"value":"ok","_meta":{"command":"unsafe"}}) + )); + } +} diff --git a/crates/opensymphony-acp/src/host.rs b/crates/opensymphony-acp/src/host.rs index 3a583a457..a5498a321 100644 --- a/crates/opensymphony-acp/src/host.rs +++ b/crates/opensymphony-acp/src/host.rs @@ -399,6 +399,27 @@ impl SessionHandle { .map_err(channel_error)?; receive.await.map_err(|_| HostError::Unavailable)? } + + /// Invoke only a registered operation on this owner and generation. The + /// session ID and wire method are resolved inside the host actor. + pub async fn operation( + &self, + run_id: String, + operation_id: String, + arguments: serde_json::Value, + ) -> Result { + let (reply, receive) = oneshot::channel(); + self.commands + .try_send(Command::Operation { + generation: self.generation, + run_id, + operation_id, + arguments, + reply, + }) + .map_err(channel_error)?; + receive.await.map_err(|_| HostError::Unavailable)? + } } fn channel_error(error: mpsc::error::TrySendError) -> HostError { match error { @@ -425,6 +446,13 @@ pub enum ControlResult { } enum Command { + Operation { + generation: u64, + run_id: String, + operation_id: String, + arguments: serde_json::Value, + reply: oneshot::Sender>, + }, Control { generation: u64, action: SessionControl, @@ -902,6 +930,17 @@ impl SessionDriver { .ready( session_id.0.to_string(), metadata, + super::extensions::configured_operations(profile) + .into_iter() + .filter(|operation| { + super::extensions::outbound_operation( + profile, + &serde_json::to_value(&initialization).unwrap_or_default(), + &operation.operation_id, + ) + .is_some() + }) + .collect(), model_selection, recovery, reset_reason, @@ -925,6 +964,7 @@ impl SessionDriver { self.publish(false); let mut active: Option = None; let mut preparing: Option = None; + let operation_permits = Arc::new(tokio::sync::Semaphore::new(8)); let mut tick = tokio::time::interval(Duration::from_millis(50)); let mut commands_closed = false; loop { @@ -999,6 +1039,68 @@ impl SessionDriver { continue; }; match command { + Command::Operation { generation, run_id, operation_id, arguments, reply } => { + if generation != self.durable.state().identity.generation + || run_id != self.durable.state().identity.run_id + { + let _ = reply.send(Err(HostError::IdentityMismatch)); + continue; + } + if active.is_none() || preparing.is_some() { + let _ = reply.send(Err(HostError::Busy)); + continue; + } + let metadata = &self.durable.state().initialization; + let Some(descriptor) = super::extensions::outbound_operation(profile, metadata, &operation_id) else { + let _ = reply.send(Err(HostError::Unavailable)); + continue; + }; + if !super::extensions::validate_echo_arguments(&arguments) { + let _ = reply.send(Err(HostError::Client("invalid registered operation arguments".into()))); + continue; + } + let Ok(operation_permit) = operation_permits.clone().try_acquire_owned() else { + let _ = reply.send(Err(HostError::ResourceLimit)); + continue; + }; + let mut params = arguments; + params["sessionId"] = serde_json::Value::String(session_id.0.to_string()); + let request = match UntypedMessage::new(super::extensions::FIXTURE_ECHO_METHOD, params) { + Ok(request) => request, + Err(_) => { + let _ = reply.send(Err(HostError::Client("invalid registered operation arguments".into()))); + continue; + } + }; + let request = connection.send_request(request); + let timeout = Duration::from_millis(descriptor.deadline_ms); + let capture = capture.clone(); + tokio::spawn(async move { + let _operation_permit = operation_permit; + // The outbound RPC has its own ID and deadline. Prompt + // completion may be decoded before the SDK wakes this + // request waiter, so ending the prompt must not discard + // an already-sent, correlated operation response. + // Keep the future and its permit after a caller deadline: + // dropping the SDK waiter sends cancellation but leaves its + // pending-reply entry until the peer responds or disconnects. + let response = request.block_task(); + tokio::pin!(response); + let result = match tokio::time::timeout(timeout, &mut response).await { + Ok(Ok(mut value)) if super::extensions::validate_echo_result(&value) => { + capture.lock().unwrap_or_else(|e| e.into_inner()).redact(&mut value, false); + Ok(value) + }, + Ok(_) => Err(HostError::Client(ClientError::OperationFailed.to_string())), + Err(_) => { + let _ = reply.send(Err(HostError::Client(ClientError::OperationTimeoutUnknown.to_string()))); + let _ = response.await; + return; + }, + }; + let _ = reply.send(result); + }); + } Command::Prompt { generation, run_id, attempt, prompt, cancellation, operator_requests, reply } => { if generation != self.durable.state().identity.generation { let _ = reply.send(Err(HostError::IdentityMismatch)); continue; } if active.is_some() || preparing.is_some() { let _ = reply.send(Err(HostError::Busy)); continue; } diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs index f939689dd..3aa89755e 100644 --- a/crates/opensymphony-acp/src/lib.rs +++ b/crates/opensymphony-acp/src/lib.rs @@ -43,11 +43,13 @@ use tracing::instrument::WithSubscriber; mod atomic_file; mod durable; +mod extensions; mod host; mod operator; mod projection; mod services; mod session_config; +pub use extensions::OperationCapability; pub use host::*; pub use operator::{ AcpOperatorDeliveryFence, AcpOperatorEvent, AcpOperatorReply, AcpOperatorRequest, @@ -196,6 +198,10 @@ pub enum ClientError { ResourceLimit { submitted: bool }, #[error("ACP child could not be terminated and reaped")] Teardown, + #[error("ACP extension operation deadline exceeded; outcome is unknown")] + OperationTimeoutUnknown, + #[error("ACP extension operation failed or returned an invalid result")] + OperationFailed, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1022,6 +1028,7 @@ async fn run_connection( .map(|driver| driver.operator_router.clone()) .unwrap_or_else(|| Arc::new(Mutex::new(None))); let permission_policy = profile.permissions.mode; + let cursor_enabled = extensions::cursor_enabled(profile); let input = FramedRead::new(stdout, LinesCodec::new_with_max_length(limits.frame_bytes)); let incoming = input.map({ let queued = queued.clone(); @@ -1163,8 +1170,41 @@ async fn run_connection( fatal.cancel(); }); } + if cursor_enabled && request.method == "cursor/update_todos" { + // The pinned CLI sends this as an ID-bearing request without + // sessionId. Bind it to the connection's active session and + // prompt epoch, never to an asserted peer session. + let session = active_session.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let active = operator_router + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .is_some_and(|(_, epoch)| !epoch.is_cancelled()); + let bound = session.is_some() + && request.params.get("sessionId") + .is_none_or(|id| id.as_str() == session.as_deref()); + let response = Ok(if active && bound + && extensions::cursor_todos(&request.params) + { + json!({"outcome":{"outcome":"accepted","todos":request.params["todos"]}}) + } else { + json!({"outcome":{"outcome":"rejected"}}) + }); + let frame = serde_json::to_string(&RawJsonRpcMessage::response( + responder.id().clone(), response.clone(), + ))?; + let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); + if !output.admit(frame, &limits) { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + return Err(agent_client_protocol::Error::internal_error()); + } + return responder.respond_with_result(response); + } let operator_method = matches!(request.method.as_str(), - "session/request_permission" | "elicitation/create"); + "session/request_permission" | "elicitation/create") + || (cursor_enabled && matches!(request.method.as_str(), + "cursor/create_plan")); if operator_method { let session = active_session.lock().unwrap_or_else(|e| e.into_inner()).clone(); let rpc_id = serde_json::to_value(responder.id())?; @@ -1213,7 +1253,7 @@ async fn run_connection( let (sender, epoch) = match route { Some((sender, epoch)) if !epoch.is_cancelled() => (sender, epoch), _ => { - let response = Ok(operator::response_for(&interaction, OperatorAnswer::Cancel)); + let response = Ok(operator::response_for_method(&request.method, &interaction, OperatorAnswer::Cancel)); let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); if !output.admit(frame, &limits) { @@ -1225,7 +1265,7 @@ async fn run_connection( } }; if let Some(answer) = operator::automatic_answer(permission_policy, &interaction) { - let response = Ok(operator::response_for(&interaction, answer)); + let response = Ok(operator::response_for_method(&request.method, &interaction, answer)); let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); if !output.admit(frame, &limits) { @@ -1242,7 +1282,7 @@ async fn run_connection( ) { Ok(reservation) => reservation, Err(_) => { - let response = Ok(operator::response_for(&interaction, OperatorAnswer::Cancel)); + let response = Ok(operator::response_for_method(&request.method, &interaction, OperatorAnswer::Cancel)); let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); if !output.admit(frame, &limits) { @@ -1276,7 +1316,7 @@ async fn run_connection( } None => (answer, None), }; - let response = Ok(operator::response_for(&interaction, answer)); + let response = Ok(operator::response_for_method(&request.method, &interaction, answer)); let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone())); let delivered = frame.ok().and_then(|frame| { reserve_and_enqueue_operator_response(&callback_output, frame, &limits, &mut acknowledgement, || responder.respond_with_result(response)) @@ -1315,7 +1355,7 @@ async fn run_connection( if request.method == "session/request_permission" { fatal.cancel(); } - let response = Ok(operator::response_for(&interaction, OperatorAnswer::Cancel)); + let response = Ok(operator::response_for_method(&request.method, &interaction, OperatorAnswer::Cancel)); let frame = serde_json::to_string(&RawJsonRpcMessage::response(responder.id().clone(), response.clone()))?; let mut output = callback_output.lock().unwrap_or_else(|e| e.into_inner()); if output.admit(frame, &limits) { diff --git a/crates/opensymphony-acp/src/operator.rs b/crates/opensymphony-acp/src/operator.rs index f6065a3fa..aab83ccf3 100644 --- a/crates/opensymphony-acp/src/operator.rs +++ b/crates/opensymphony-acp/src/operator.rs @@ -9,6 +9,7 @@ use agent_client_protocol::schema::v1::{ MultiSelectItems, RequestPermissionRequest, }; use chrono::{Duration as ChronoDuration, Utc}; +use serde::Deserialize; use std::{ collections::{HashMap, HashSet}, sync::atomic::{AtomicU8, Ordering}, @@ -76,6 +77,89 @@ fn no_secret_prompt(value: &str) -> bool { .any(|needle| lower.contains(needle)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CursorQuestionRequest { + session_id: String, + tool_call_id: String, + title: Option, + questions: Vec, + #[serde(rename = "_meta", default)] + meta: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CursorQuestion { + id: String, + prompt: String, + options: Vec, + #[serde(default)] + allow_multiple: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CursorOption { + id: String, + label: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CursorPlanRequest { + session_id: Option, + tool_call_id: String, + name: Option, + overview: Option, + plan: String, + todos: Vec, + #[serde(default)] + is_project: bool, + #[serde(default)] + phases: Vec, + #[serde(rename = "_meta", default)] + meta: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CursorPhase { + name: String, + todos: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CursorTodo { + id: String, + content: String, + status: String, +} + +fn valid_cursor_meta(meta: &Option) -> bool { + meta.as_ref().is_none_or(|meta| { + meta.as_object().is_some_and(|fields| { + fields.len() <= 8 + && fields.iter().all(|(key, value)| { + (key.contains('/') + || matches!(key.as_str(), "traceparent" | "tracestate" | "baggage")) + && key.len() <= 128 + && serde_json::to_vec(value).is_ok_and(|bytes| bytes.len() <= 4096) + }) + }) + }) +} + +fn valid_cursor_todo(todo: &CursorTodo) -> bool { + bounded(&todo.id, 128) + && bounded(&todo.content, 2048) + && matches!( + todo.status.as_str(), + "pending" | "in_progress" | "completed" | "cancelled" + ) +} + pub(super) fn same_binding_ids(safe: &OperatorInteraction, original: &OperatorInteraction) -> bool { let ids = |interaction: &OperatorInteraction| { let options = interaction @@ -258,10 +342,100 @@ pub(super) fn parse_interaction( None, ) } + "cursor/ask_question" => { + let request: CursorQuestionRequest = + serde_json::from_value(params.clone()).map_err(|_| invalid())?; + if request.session_id != session_id + || !bounded(&request.tool_call_id, 128) + || !valid_cursor_meta(&request.meta) + || request.questions.is_empty() + || request.questions.len() > 8 + { + return Err(invalid()); + } + let title = request.title.unwrap_or_else(|| "Cursor question".into()); + let mut ids = HashSet::new(); + let mut questions = Vec::new(); + for question in request.questions { + if !bounded(&question.id, 128) + || !ids.insert(question.id.clone()) + || !bounded(&question.prompt, 2048) + || !no_secret_prompt(&question.prompt) + || question.options.is_empty() + || question.options.len() > 32 + { + return Err(invalid()); + } + let mut option_ids = HashSet::new(); + let options = question + .options + .into_iter() + .map(|option| { + if !bounded(&option.id, 128) + || !bounded(&option.label, 1024) + || !no_secret_prompt(&option.label) + || !option_ids.insert(option.id.clone()) + { + return Err(invalid()); + } + Ok(OperatorOption { + id: option.id, + label: option.label, + kind: "choice".into(), + }) + }) + .collect::, _>>()?; + questions.push(OperatorQuestion { + id: question.id, + prompt: question.prompt, + options, + allow_multiple: question.allow_multiple, + }); + } + ( + OperatorInteractionKind::Question, + title, + Vec::new(), + questions, + None, + ) + } + "cursor/create_plan" => { + let request: CursorPlanRequest = + serde_json::from_value(params.clone()).map_err(|_| invalid())?; + if request + .session_id + .as_deref() + .is_some_and(|id| id != session_id) + || !bounded(&request.tool_call_id, 128) + || !valid_cursor_meta(&request.meta) + || request.plan.trim().is_empty() + || request.plan.len() > 64 * 1024 + || request.todos.len() > 128 + || request.phases.len() > 32 + || request.todos.iter().any(|todo| !valid_cursor_todo(todo)) + || request.phases.iter().any(|phase| { + !bounded(&phase.name, 1024) + || phase.todos.len() > 128 + || phase.todos.iter().any(|todo| !valid_cursor_todo(todo)) + }) + { + return Err(invalid()); + } + let _ = request.is_project; + let _ = request.overview; + ( + OperatorInteractionKind::PlanApproval, + request.name.unwrap_or_else(|| "Cursor plan".into()), + Vec::new(), + Vec::new(), + Some(request.plan), + ) + } _ => return Err(agent_client_protocol::Error::method_not_found()), }; if !bounded(&title, 2048) - || !no_secret_prompt(&title) + || (kind != OperatorInteractionKind::PlanApproval && !no_secret_prompt(&title)) || !rpc_id.is_number() && !rpc_id.is_string() { return Err(invalid()); @@ -392,6 +566,38 @@ pub(super) fn response_for(interaction: &OperatorInteraction, answer: OperatorAn } } +/// Cursor's documented question result uses an `outcome` envelope instead of +/// standard ACP form elicitation's `action`/`content` result. +pub(super) fn response_for_method( + method: &str, + interaction: &OperatorInteraction, + answer: OperatorAnswer, +) -> Value { + let response = response_for(interaction, answer); + if method != "cursor/ask_question" { + return response; + } + match response.get("action").and_then(Value::as_str) { + Some("accept") => { + let answers = interaction + .questions + .iter() + .map(|question| { + let selected = &response["content"][&question.id]; + let selected_option_ids = selected + .as_array() + .cloned() + .unwrap_or_else(|| vec![selected.clone()]); + json!({"questionId": question.id, "selectedOptionIds": selected_option_ids}) + }) + .collect::>(); + json!({"outcome":{"outcome":"answered","answers":answers}}) + } + Some("decline") => json!({"outcome":{"outcome":"skipped"}}), + _ => json!({"outcome":{"outcome":"cancelled"}}), + } +} + #[cfg(test)] mod tests { use super::*; @@ -547,6 +753,68 @@ mod tests { ); } + #[test] + fn cursor_question_and_plan_use_vendor_response_contracts() { + let question = parse_interaction( + "cursor/ask_question", + &json!({"sessionId":"s","toolCallId":"call-q","questions":[{"id":"mode","prompt":"Mode?","options":[{"id":"agent","label":"Agent"},{"id":"plan","label":"Plan"}]}],"_meta":{"traceparent":"trace"}}), + json!(0), "s", Duration::from_secs(30), + ).expect("Cursor question"); + let answered = response_for_method( + "cursor/ask_question", + &question, + OperatorAnswer::Question { + answers: vec![OperatorQuestionAnswer { + question_id: "mode".into(), + selected_option_ids: vec!["plan".into()], + }], + }, + ); + assert_eq!( + answered, + json!({"outcome":{"outcome":"answered","answers":[{"questionId":"mode","selectedOptionIds":["plan"]}]}}) + ); + assert_eq!( + response_for_method("cursor/ask_question", &question, OperatorAnswer::Decline), + json!({"outcome":{"outcome":"skipped"}}) + ); + assert_eq!( + response_for_method("cursor/ask_question", &question, OperatorAnswer::Cancel), + json!({"outcome":{"outcome":"cancelled"}}) + ); + assert!( + parse_interaction( + "cursor/ask_question", + &json!({"sessionId":"other","toolCallId":"call-q","questions":[]}), + json!(0), + "s", + Duration::from_secs(30) + ) + .is_err() + ); + + let plan = parse_interaction( + "cursor/create_plan", + &json!({"sessionId":"s","toolCallId":"call-p","plan":"1. Verify","todos":[{"id":"t1","content":"Verify","status":"pending"}]}), + json!(1), "s", Duration::from_secs(30), + ).expect("Cursor plan"); + assert_eq!(plan.kind, OperatorInteractionKind::PlanApproval); + assert_eq!( + response_for_method( + "cursor/create_plan", + &plan, + OperatorAnswer::Plan { accepted: true } + ), + json!({"outcome":{"outcome":"accepted"}}) + ); + let credential_plan = parse_interaction( + "cursor/create_plan", + &json!({"sessionId":"s","toolCallId":"call-credentials","name":"Rotate credentials","plan":"Validate token handling","todos":[{"id":"t1","content":"Update secret storage","status":"pending"}]}), + json!(2), "s", Duration::from_secs(30), + ).expect("plan text can discuss credential-related code"); + assert_eq!(credential_plan.title, "Rotate credentials"); + } + #[test] fn plan_response_and_sensitive_question_boundary() { let mut interaction = parse_interaction( diff --git a/crates/opensymphony-acp/src/projection.rs b/crates/opensymphony-acp/src/projection.rs index 08678a782..68fadc005 100644 --- a/crates/opensymphony-acp/src/projection.rs +++ b/crates/opensymphony-acp/src/projection.rs @@ -1,5 +1,8 @@ //! Scheduler projection of the redacted source stream; wire evidence stays on the owner. -use std::collections::{BTreeMap, BTreeSet}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; use serde_json::{Value, json}; @@ -39,8 +42,23 @@ pub(super) fn reported_turn_usage(payload: &Value) -> Option, + todo_count: usize, +} + #[derive(Default)] pub struct RuntimeProjection { + cursor_enabled: bool, + cursor_session_id: Option, + // Redacted, bounded candidates become activity only after the host's + // correlated accepted response. A rejected callback is not a todo update. + cursor_todo_candidates: BTreeMap, + cursor_todo_pending: usize, + cursor_rpc_counts: BTreeMap, + cursor_rpc_pending: usize, + cursor_callback_saturated: bool, last: Option<(u64, u64)>, tools: BTreeMap, tool_bytes: usize, @@ -59,10 +77,25 @@ impl RuntimeProjection { } } + pub fn with_cursor( + mut self, + enabled: bool, + session_id: Option<&str>, + _workspace: Option<&Path>, + ) -> Self { + self.cursor_enabled = enabled; + self.cursor_session_id = session_id.map(str::to_owned); + self + } + pub fn last_cursor(&self) -> Option<(u64, u64)> { self.last } + pub fn cursor_callback_saturated(&self) -> bool { + self.cursor_callback_saturated + } + pub fn apply(&mut self, event: &SessionEvent, run_id: &str) -> Option { let SessionEvent::Source { generation, @@ -77,6 +110,13 @@ impl RuntimeProjection { if self.last.is_some_and(|last| cursor <= last) { return None; } + if self.last.is_some_and(|last| last.0 != *generation) { + self.cursor_todo_candidates.clear(); + self.cursor_todo_pending = 0; + self.cursor_rpc_counts.clear(); + self.cursor_rpc_pending = 0; + self.cursor_callback_saturated = false; + } self.last = Some(cursor); if *replay || source_run != run_id { return None; @@ -85,6 +125,40 @@ impl RuntimeProjection { if frame.payload["method"] == "session/prompt" { self.prompt_request = frame.payload.get("id").cloned(); } + if self.cursor_enabled + && frame.payload.get("method").is_none() + && let Some(id) = frame.payload.get("id") + && (id.is_number() || id.is_string()) + { + let key = id.to_string(); + let mut payload = None; + let drained = if let Some(count) = self.cursor_rpc_counts.get_mut(&key) { + *count -= 1; + self.cursor_rpc_pending -= 1; + *count == 0 + } else { + false + }; + if drained { + self.cursor_rpc_counts.remove(&key); + if let Some(candidate) = self.cursor_todo_candidates.remove(&key) { + self.cursor_todo_pending -= candidate.todo_count; + payload = candidate.payload; + } + } + if let Some(payload) = payload + && frame.payload.pointer("/result/outcome/outcome") == Some(&json!("accepted")) + { + return Some(RuntimeUpdate { + sequence: frame.sequence, + generation: *generation, + observed_at: frame.observed_at, + kind: "vendor_todos".into(), + summary: Some("Cursor todos updated".into()), + payload, + }); + } + } return self .project_filesystem_response(*generation, frame) .or_else(|| self.project_terminal_response(*generation, frame)); @@ -92,6 +166,73 @@ impl RuntimeProjection { if frame.direction != "incoming" { return None; } + let saturation = || RuntimeUpdate { + sequence: frame.sequence, + generation: *generation, + observed_at: frame.observed_at, + kind: "cursor_todo_saturation".into(), + summary: Some("ACP Cursor callback projection saturated; worker fenced".into()), + payload: json!({"pending_limit":16,"rpc_pending_limit":128,"rpc_id_limit":128}), + }; + // Track every peer request ID, including plans and standard callbacks. + // A concurrent plan/todo ID collision cannot be distinguished by the + // response envelope, so its todo candidate must remain ambiguous. + let cursor_request = if self.cursor_enabled + && self.cursor_session_id.is_some() + && frame + .payload + .get("method") + .and_then(Value::as_str) + .is_some() + && let Some(id) = frame.payload.get("id") + && (id.is_number() || id.is_string()) + { + let key = id.to_string(); + if key.len() > 128 || self.cursor_rpc_pending >= 128 { + self.cursor_callback_saturated = true; + return Some(saturation()); + } + let count = self.cursor_rpc_counts.entry(key.clone()).or_default(); + let ambiguous = *count > 0; + *count += 1; + self.cursor_rpc_pending += 1; + if ambiguous && let Some(candidate) = self.cursor_todo_candidates.get_mut(&key) { + candidate.payload = None; + } + Some((key, ambiguous)) + } else { + None + }; + if self.cursor_enabled + && self.cursor_session_id.is_some() + && frame.payload.get("method").and_then(Value::as_str) == Some("cursor/update_todos") + && let Some(params) = frame.payload.get("params") + && params + .get("sessionId") + .is_none_or(|id| id.as_str() == self.cursor_session_id.as_deref()) + && super::extensions::cursor_todos(params) + && let Some((key, ambiguous)) = cursor_request + { + if self.cursor_todo_pending >= 16 { + self.cursor_callback_saturated = true; + return Some(saturation()); + } + self.cursor_todo_pending += 1; + if let Some(candidate) = self.cursor_todo_candidates.get_mut(&key) { + candidate.payload = None; + candidate.todo_count += 1; + } else { + self.cursor_todo_candidates.insert( + key, + CursorTodoCandidate { + payload: (!ambiguous) + .then(|| json!({"todos":params["todos"], "merge":params["merge"]})), + todo_count: 1, + }, + ); + } + return None; + } if let Some(activity) = self.project_filesystem_request(*generation, frame) { return Some(activity); } @@ -426,6 +567,7 @@ pub fn run_capability( crate::opensymphony_gateway_schema::capability::HarnessRunCapability { harness: "acp".into(), profile_id: state.identity.profile_id.clone(), + run_binding_id: Some(state.identity.run_id.clone()), protocol: "acp".into(), protocol_version: 1, rpc: "json_rpc_2_0".into(), @@ -440,6 +582,7 @@ pub fn run_capability( model_selection: state.model_selection, cancellation: true, operator_responses: true, + operations: state.enabled_operations.clone(), } } @@ -505,7 +648,7 @@ fn profile_capabilities_with_environment( } None => value("PATH"), }; - let command = std::path::Path::new(&profile.command); + let command = Path::new(&profile.command); let available = if command.components().count() > 1 { command.is_absolute() && executable(command) } else { @@ -543,12 +686,13 @@ fn profile_capabilities_with_environment( profile_id: id.clone(), preflight_ready: reason.is_none(), unavailable_reason: reason.map(str::to_owned), + operations: super::extensions::configured_operations(profile), } }) .collect() } -fn executable(path: &std::path::Path) -> bool { +fn executable(path: &Path) -> bool { #[cfg(windows)] if path.extension().is_none() && path.with_extension("exe").is_file() { return true; @@ -604,6 +748,19 @@ mod tests { profile_capabilities_with_environment(&config, &environment, &BTreeSet::new())[0] .preflight_ready ); + config + .profiles + .get_mut("profile") + .expect("profile") + .extensions + .push("fixture_echo@1".into()); + let advertised = + profile_capabilities_with_environment(&config, &environment, &BTreeSet::new()); + assert_eq!(advertised[0].operations[0].operation_id, "fixture.echo"); + assert_eq!( + advertised[0].operations[0].parameters_schema["required"], + json!(["value"]) + ); let profile = config.profiles.get_mut("profile").expect("profile"); profile.env_refs.insert("PATH".into(), "MISSING".into()); assert_eq!( @@ -766,6 +923,219 @@ mod tests { } } + #[test] + fn cursor_todo_request_projects_only_for_enabled_bound_session() { + let payload = |session: Option<&str>| { + let mut value = json!({"id":0,"method":"cursor/update_todos","params":{ + "toolCallId":"todo-1","merge":true, + "todos":[{"id":"one","content":"Verify","status":"completed"}]}}); + if let Some(session) = session { + value["params"]["sessionId"] = json!(session); + } + value + }; + let mut disabled = RuntimeProjection::default(); + assert!( + disabled + .apply(&callback_event(1, "incoming", payload(None)), "run") + .is_none() + ); + let mut enabled = RuntimeProjection::default().with_cursor(true, Some("s"), None); + let mut wrong_session = payload(Some("other")); + wrong_session["id"] = json!(99); + assert!( + enabled + .apply(&callback_event(1, "incoming", wrong_session), "run") + .is_none() + ); + assert!( + enabled + .apply(&callback_event(2, "incoming", payload(None)), "run") + .is_none() + ); + let update = enabled + .apply( + &callback_event( + 3, + "outgoing", + json!({"id":0,"result":{"outcome":{"outcome":"accepted","todos":[]}}}), + ), + "run", + ) + .expect("accepted bound update"); + assert_eq!(update.kind, "vendor_todos"); + assert_eq!(update.payload["todos"][0]["content"], "Verify"); + assert!(enabled.apply(&callback_event(4, "incoming", json!({"method":"cursor/update_todos","params":{"toolCallId":"todo-1","merge":true,"todos":[]}})), "run").is_none()); + assert!(enabled.apply(&callback_event(5, "incoming", json!({"id":100,"method":"cursor/update_todos","params":{"sessionId":7,"toolCallId":"todo-1","merge":false,"todos":[]}})), "run").is_none()); + assert!(enabled.apply(&callback_event(6, "incoming", json!({"id":101,"method":"cursor/task","params":{"toolCallId":"task","description":"unqualified","prompt":"noop","subagentType":"general"}})), "run").is_none()); + assert!( + enabled + .apply(&callback_event(7, "incoming", payload(None)), "run") + .is_none() + ); + assert!( + enabled + .apply( + &callback_event( + 8, + "outgoing", + json!({"id":0,"result":{"outcome":{"outcome":"rejected"}}}) + ), + "run" + ) + .is_none() + ); + assert!(enabled.cursor_todo_candidates.is_empty()); + assert!( + enabled + .apply(&callback_event(9, "incoming", payload(None)), "run") + .is_none() + ); + let mut next_generation = callback_event( + 1, + "outgoing", + json!({"id":0,"result":{"outcome":{"outcome":"accepted"}}}), + ); + if let SessionEvent::Source { generation, .. } = &mut next_generation { + *generation += 1; + } + assert!(enabled.apply(&next_generation, "run").is_none()); + assert!(enabled.cursor_todo_candidates.is_empty()); + } + + #[test] + fn cursor_todo_candidates_are_bounded_and_duplicate_ids_are_ambiguous() { + let mut projection = RuntimeProjection::default().with_cursor(true, Some("s"), None); + for sequence in 1..=16 { + let request = json!({"id":sequence,"method":"cursor/update_todos","params":{ + "toolCallId":"todo","merge":false,"todos":[]}}); + assert!( + projection + .apply(&callback_event(sequence, "incoming", request), "run") + .is_none() + ); + } + assert_eq!(projection.cursor_todo_candidates.len(), 16); + let saturated = projection.apply(&callback_event(17, "incoming", json!({"id":17,"method":"cursor/update_todos","params":{"toolCallId":"todo","merge":false,"todos":[]}})), "run").expect("visible saturation"); + assert_eq!(saturated.kind, "cursor_todo_saturation"); + assert_eq!(saturated.payload["pending_limit"], 16); + assert!(projection.cursor_callback_saturated()); + + let mut duplicate = RuntimeProjection::default().with_cursor(true, Some("s"), None); + let request = |sequence, content| { + callback_event( + sequence, + "incoming", + json!({"id":0,"method":"cursor/update_todos","params":{ + "toolCallId":"todo","merge":false,"todos":[{"id":"one","content":content,"status":"completed"}]}}), + ) + }; + let response = |sequence| { + callback_event( + sequence, + "outgoing", + json!({"id":0,"result":{"outcome":{"outcome":"accepted"}}}), + ) + }; + assert!(duplicate.apply(&request(1, "A"), "run").is_none()); + assert!(duplicate.apply(&request(2, "B"), "run").is_none()); + assert!(duplicate.apply(&response(3), "run").is_none()); + assert!(duplicate.apply(&request(4, "C"), "run").is_none()); + assert!(duplicate.apply(&response(5), "run").is_none()); + assert!(duplicate.apply(&response(6), "run").is_none()); + assert_eq!(duplicate.cursor_todo_pending, 0); + assert!(duplicate.cursor_todo_candidates.is_empty()); + assert!(duplicate.apply(&request(7, "D"), "run").is_none()); + let update = duplicate + .apply(&response(8), "run") + .expect("fresh ID after full drain"); + assert_eq!(update.payload["todos"][0]["content"], "D"); + } + + #[test] + fn cursor_plan_and_todo_with_same_id_cannot_cross_correlate() { + for plan_first in [true, false] { + let mut projection = RuntimeProjection::default().with_cursor(true, Some("s"), None); + let plan = json!({"id":0,"method":"cursor/create_plan","params":{"toolCallId":"plan","plan":"Answer in chat","todos":[]}}); + let todo = json!({"id":0,"method":"cursor/update_todos","params":{"toolCallId":"todo","merge":false,"todos":[{"id":"one","content":"Todo C","status":"completed"}]}}); + let (first, second) = if plan_first { + (plan, todo) + } else { + (todo, plan) + }; + assert!( + projection + .apply(&callback_event(1, "incoming", first), "run") + .is_none() + ); + assert!( + projection + .apply(&callback_event(2, "incoming", second), "run") + .is_none() + ); + assert!( + projection + .apply( + &callback_event( + 3, + "outgoing", + json!({"id":0,"result":{"outcome":{"outcome":"accepted"}}}) + ), + "run" + ) + .is_none() + ); + assert!( + projection + .apply( + &callback_event( + 4, + "outgoing", + json!({"id":0,"result":{"outcome":{"outcome":"rejected"}}}) + ), + "run" + ) + .is_none() + ); + assert_eq!(projection.cursor_rpc_pending, 0); + assert_eq!(projection.cursor_todo_pending, 0); + assert!(projection.cursor_todo_candidates.is_empty()); + } + } + + #[test] + fn cursor_callback_id_tracking_has_visible_global_bound() { + let mut projection = RuntimeProjection::default().with_cursor(true, Some("s"), None); + for sequence in 1..=128 { + assert!( + projection + .apply( + &callback_event( + sequence, + "incoming", + json!({"id":sequence,"method":"cursor/create_plan","params":{}}) + ), + "run" + ) + .is_none() + ); + } + assert_eq!(projection.cursor_rpc_pending, 128); + let update = projection + .apply( + &callback_event( + 129, + "incoming", + json!({"id":129,"method":"cursor/create_plan","params":{}}), + ), + "run", + ) + .expect("visible global bound"); + assert_eq!(update.kind, "cursor_todo_saturation"); + assert_eq!(update.payload["rpc_pending_limit"], 128); + assert!(projection.cursor_callback_saturated()); + } + #[test] fn terminal_callback_responses_project_completed_command_once() { let mut projection = RuntimeProjection::default(); diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 58dd703e8..0c6b86c13 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -10407,6 +10407,36 @@ impl WorkerBackend for RuntimeWorkerBackend { ) } + async fn begin_harness_operation( + &mut self, + worker_id: &crate::opensymphony_domain::WorkerId, + run_id: &str, + operation_id: &str, + arguments: serde_json::Value, + ) -> Result { + let session = self + .acp_active + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(worker_id.as_str()) + .filter(|session| session.run_id == run_id) + .cloned() + .ok_or_else(|| { + CliWorkerError::OperatorResponseRetryable("ACP run is not active".into()) + })?; + let run_id = run_id.to_owned(); + let operation_id = operation_id.to_owned(); + Ok( + crate::opensymphony_orchestrator::HarnessOperationDelivery::new(async move { + session + .handle + .operation(run_id, operation_id, arguments) + .await + .map_err(|error| error.to_string()) + }), + ) + } + async fn abort_worker( &mut self, worker_id: &crate::opensymphony_domain::WorkerId, @@ -17807,11 +17837,20 @@ exit 64 .await .expect("gateway command timeout") .expect("command"); - assert_eq!(command.interaction, interaction); + let OperatorCommand::Response { + interaction: command_interaction, + answer, + reply, + .. + } = command + else { + panic!("operator response command"); + }; + assert_eq!(command_interaction, interaction); let delivered = scheduler - .respond_operator_request(&command.interaction, command.answer) + .respond_operator_request(&command_interaction, answer) .await; - command.reply.send(delivered).expect("gateway reply"); + reply.send(delivered).expect("gateway reply"); let receipt: ActionReceipt = response .await .expect("response task") diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs b/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs index d6d3fb471..071b97168 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends/acp.rs @@ -630,6 +630,10 @@ async fn try_run( profile_fingerprint, credential_scope, } = effective_launch_identity(route, workflow, &worker_environment, &environment, &limits)?; + let cursor_enabled = profile + .extensions + .iter() + .any(|id| id == "cursor@2026.09.08-6caf4ff"); let profile_id = route .harness_profile .as_ref() @@ -771,7 +775,11 @@ async fn try_run( .source_history() .latest_cursor .unwrap_or((handle.generation, 0)); - let mut projection = RuntimeProjection::after(baseline); + let mut projection = RuntimeProjection::after(baseline).with_cursor( + cursor_enabled, + snapshot.state.session_id.as_deref(), + Some(&snapshot.state.identity.workspace_path), + ); let prompt = handle.prompt_with_operator( manifest.run_id.clone(), manifest.attempt, @@ -816,11 +824,11 @@ async fn try_run( }, event = receiver.recv() => match event { Ok(SessionEvent::Gap { .. }) => return Err("ACP source stream lost an oversized frame; submission remains fenced".into()), - Ok(event) => project_event(&event, &mut projection, &mut capability, &manifest.run_id, &run.worker_id, updates), + Ok(event) => project_event(&event, &mut projection, &mut capability, &manifest.run_id, &run.worker_id, updates)?, Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { let history = handle.source_history(); if !history.covers_since(projection.last_cursor().unwrap_or(baseline)) { return Err("ACP worker source stream exceeded retention; submission remains fenced".into()); } - for event in history.events { project_event(&event, &mut projection, &mut capability, &manifest.run_id, &run.worker_id, updates); } + for event in history.events { project_event(&event, &mut projection, &mut capability, &manifest.run_id, &run.worker_id, updates)?; } } Err(tokio::sync::broadcast::error::RecvError::Closed) => break Err(crate::opensymphony_acp::HostError::Unavailable), } @@ -839,7 +847,7 @@ async fn try_run( &manifest.run_id, &run.worker_id, updates, - ), + )?, Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => { let history = handle.source_history(); if !history.covers_since(projection.last_cursor().unwrap_or(baseline)) { @@ -853,7 +861,7 @@ async fn try_run( &manifest.run_id, &run.worker_id, updates, - ); + )?; } } Err(_) => break, @@ -890,7 +898,7 @@ fn project_event( run_id: &str, worker_id: &crate::opensymphony_domain::WorkerId, updates: &mpsc::UnboundedSender, -) { +) -> Result<(), String> { if let SessionEvent::State { snapshot } = event && snapshot.state.identity.run_id == run_id { @@ -914,6 +922,10 @@ fn project_event( payload, }); } + if projection.cursor_callback_saturated() { + return Err("ACP Cursor callback projection saturated; submission remains fenced".into()); + } + Ok(()) } fn forward_operator_response( @@ -944,6 +956,54 @@ mod tests { #[cfg(unix)] use std::os::unix::ffi::OsStringExt; + #[test] + fn cursor_todo_projection_saturation_emits_diagnostic_and_fails_run() { + let mut projection = RuntimeProjection::default().with_cursor(true, Some("session"), None); + let mut capability = serde_json::from_value(serde_json::json!({ + "harness":"acp","profile_id":"cursor","protocol":"acp","protocol_version":1, + "rpc":"json_rpc","encoding":"json","framing":"line_delimited","carrier":"stdio", + "session_restore":false,"history_replay":false,"model_selection":false, + "cancellation":true,"operator_responses":true + })) + .expect("capability"); + let worker = crate::opensymphony_domain::WorkerId::new("worker-cursor").expect("worker"); + let (updates, mut received) = mpsc::unbounded_channel(); + for sequence in 1..=17 { + let event = SessionEvent::Source { + generation: 1, + run_id: "run".into(), + replay: false, + frame: crate::opensymphony_acp::SourceFrame { + sequence, + direction: "incoming".into(), + observed_at: chrono::Utc::now(), + payload: serde_json::json!({"id":sequence,"method":"cursor/update_todos","params":{ + "toolCallId":"todo","merge":false,"todos":[]}}), + }, + }; + let result = project_event( + &event, + &mut projection, + &mut capability, + "run", + &worker, + &updates, + ); + if sequence < 17 { + assert!(result.is_ok()); + } else { + assert!( + result + .expect_err("saturation fails run") + .contains("saturated") + ); + } + } + assert!( + matches!(received.try_recv(), Ok(WorkerUpdate::RuntimeEvent { event_kind: Some(kind), .. }) if kind == "acp.cursor_todo_saturation") + ); + } + #[tokio::test] async fn expired_operator_ack_fences_delayed_worker_consume() { let delivery = crate::opensymphony_acp::AcpOperatorDeliveryFence::default(); diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index 8650b6a96..fa7905899 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -2998,7 +2998,7 @@ scheduler: for (replacement, expected) in [ ( "command: python3\n extensions: [unknown]", - "extensions must be empty", + "extensions must name distinct supported contract versions", ), ( "command: python3\n protocol_versions: [2]", diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index b209617c0..3d8c27eb8 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -1378,37 +1378,67 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { return Err(RunCommandError::Serve(io::Error::other(error.to_string()))); } }, - RunWake::OperatorCommand(command) => { - if !command.delivery.claim() { - let _ = command - .reply - .send(Err("operator response delivery timed out".into())); - continue; - } - let crate::opensymphony_gateway::OperatorCommand { + RunWake::OperatorCommand(command) => match *command { + crate::opensymphony_gateway::OperatorCommand::Response { interaction, answer, reply, - .. - } = *command; - match scheduler - .begin_operator_response(&interaction, answer) - .await - { - Ok(delivery) => { - spawn_operator_delivery_completion( - delivery, - interaction, - reply, - operator_deliveries_tx.clone(), - operator_update_notify.clone(), - ); + delivery, + } => { + if !delivery.claim() { + let _ = reply.send(Err("operator response delivery timed out".into())); + continue; } - Err(error) => { - let _ = reply.send(Err(error)); + match scheduler + .begin_operator_response(&interaction, answer) + .await + { + Ok(delivery) => { + spawn_operator_delivery_completion( + delivery, + interaction, + reply, + operator_deliveries_tx.clone(), + operator_update_notify.clone(), + ); + } + Err(error) => { + let _ = reply.send(Err(error)); + } } } - } + crate::opensymphony_gateway::OperatorCommand::HarnessOperation { + issue_identifier, + run_id, + operation_id, + arguments, + reply, + delivery, + } => { + if !delivery.claim() { + let _ = reply.send(Err("harness operation dispatch timed out".into())); + continue; + } + match scheduler + .begin_harness_operation( + &issue_identifier, + &run_id, + &operation_id, + arguments, + ) + .await + { + Ok(delivery) => { + tokio::spawn(async move { + let _ = reply.send(delivery.wait().await); + }); + } + Err(error) => { + let _ = reply.send(Err(error)); + } + } + } + }, RunWake::OperatorUpdate => { while let Ok(completed) = operator_deliveries_rx.try_recv() { let result = scheduler @@ -2346,7 +2376,7 @@ mod tests { let now = Utc::now(); let (reply, _received) = tokio::sync::oneshot::channel(); commands_tx - .send(OperatorCommand { + .send(OperatorCommand::Response { interaction: OperatorInteraction { request_id: "request".into(), run_id: "run".into(), diff --git a/crates/opensymphony-gateway-schema/src/action.rs b/crates/opensymphony-gateway-schema/src/action.rs index f4cdd8905..56b713223 100644 --- a/crates/opensymphony-gateway-schema/src/action.rs +++ b/crates/opensymphony-gateway-schema/src/action.rs @@ -28,6 +28,8 @@ pub struct ActionReceipt { pub correlation_id: String, pub status: ActionStatus, pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, /// Timestamp when the receipt was issued (ISO 8601 / RFC 3339). pub issued_at: String, /// Hosted-mode permission check placeholder. @@ -92,6 +94,7 @@ pub enum ActionKind { ApprovalDecision, InputResponse, PlanDecision, + HarnessOperation, PublishPlan, /// Create or update a Linear project milestone. TaskGraphMilestone, @@ -123,6 +126,7 @@ impl std::fmt::Display for ActionKind { ActionKind::ApprovalDecision => "approval_decision", ActionKind::InputResponse => "input_response", ActionKind::PlanDecision => "plan_decision", + ActionKind::HarnessOperation => "harness_operation", ActionKind::PublishPlan => "publish_plan", ActionKind::TaskGraphMilestone => "task_graph_milestone", ActionKind::TaskGraphIssue => "task_graph_issue", @@ -192,6 +196,7 @@ impl ActionKind { ActionKind::InputResponse | ActionKind::PlanDecision => { vec![ExpectedFollowup::ActionCompletion] } + ActionKind::HarnessOperation => vec![ExpectedFollowup::ActionCompletion], ActionKind::PublishPlan => vec![ ExpectedFollowup::ActionCompletion, ExpectedFollowup::JournalUpdate, @@ -240,6 +245,7 @@ impl ActionReceipt { correlation_id: correlation_id.into(), status: ActionStatus::Accepted, reason: None, + result: None, issued_at: Utc::now().to_rfc3339(), permission: None, expected_followup: action_kind.expected_followups(), @@ -259,6 +265,7 @@ impl ActionReceipt { correlation_id: correlation_id.into(), status: ActionStatus::Rejected, reason: Some(reason.into()), + result: None, issued_at: Utc::now().to_rfc3339(), permission: None, expected_followup: action_kind.expected_followups(), diff --git a/crates/opensymphony-gateway-schema/src/capability.rs b/crates/opensymphony-gateway-schema/src/capability.rs index 8cc528d07..f78308175 100644 --- a/crates/opensymphony-gateway-schema/src/capability.rs +++ b/crates/opensymphony-gateway-schema/src/capability.rs @@ -519,6 +519,22 @@ pub struct HarnessProfileCapability { pub profile_id: String, pub preflight_ready: bool, pub unavailable_reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operations: Vec, +} + +/// Operator-owned extension operation. Arguments omit the session and wire method; +/// the ACP owner supplies those after binding the current run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HarnessOperationCapability { + pub operation_id: String, + pub namespace: String, + pub version: String, + pub capability_predicate: String, + pub parameters_schema: serde_json::Value, + pub result_schema: serde_json::Value, + pub deadline_ms: u64, + pub effect: String, } /// Effective features negotiated for one execution; absent usage remains absent. @@ -526,6 +542,9 @@ pub struct HarnessProfileCapability { pub struct HarnessRunCapability { pub harness: String, pub profile_id: String, + /// Opaque scheduler attempt binding required for operator operations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_binding_id: Option, pub protocol: String, pub protocol_version: u16, pub rpc: String, @@ -537,4 +556,6 @@ pub struct HarnessRunCapability { pub model_selection: bool, pub cancellation: bool, pub operator_responses: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operations: Vec, } diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 091e0f26d..6a41b9b05 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -931,6 +931,21 @@ fn action_dispatch_roundtrips() { assert_eq!(back.action_kind, ActionKind::Retry); assert_eq!(back.correlation_id, "corr-1"); assert_eq!(back.idempotency_key, Some("idem-1".into())); + let operation = ActionDispatch { + action_kind: ActionKind::HarnessOperation, + payload: Some( + serde_json::json!({"run_id":"run-1","operation_id":"fixture.echo","arguments":{"value":"hello"}}), + ), + idempotency_key: None, + ..action + }; + let json = must_serialize(&operation); + let back: ActionDispatch = must_deserialize(&json); + assert_eq!(back.action_kind, ActionKind::HarnessOperation); + assert_eq!( + back.payload.expect("operation payload")["operation_id"], + "fixture.echo" + ); } #[test] @@ -1711,6 +1726,8 @@ fn acp_profile_and_run_capabilities_share_typescript_fixture() { serde_json::from_value(fixture["run"].clone()).expect("run DTO"); assert!(!profile.preflight_ready); assert!(run.session_restore); + assert_eq!(profile.operations[0].operation_id, "fixture.echo"); + assert_eq!(run.operations[0].deadline_ms, 5000); assert_eq!( serde_json::to_value(profile).expect("profile round trip"), fixture["profile"] diff --git a/crates/opensymphony-gateway/src/action_handler.rs b/crates/opensymphony-gateway/src/action_handler.rs index ada51cc0d..42baf1e06 100644 --- a/crates/opensymphony-gateway/src/action_handler.rs +++ b/crates/opensymphony-gateway/src/action_handler.rs @@ -185,6 +185,7 @@ impl ActionHandler { ActionKind::InputResponse | ActionKind::PlanDecision => { validate_generic(&action, issue.as_ref(), &action_id) } + ActionKind::HarnessOperation => validate_generic(&action, issue.as_ref(), &action_id), ActionKind::PublishPlan => validate_generic(&action, issue.as_ref(), &action_id), ActionKind::TaskGraphMilestone => validate_task_graph(&action, &action_id), ActionKind::TaskGraphIssue => validate_task_graph(&action, &action_id), diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index 74c77fc67..36780035b 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -292,11 +292,21 @@ impl Drop for BrokerConnectionGuard { } /// Shared state for the gateway server. -pub struct OperatorCommand { - pub interaction: crate::opensymphony_gateway_schema::approval::OperatorInteraction, - pub answer: crate::opensymphony_gateway_schema::approval::OperatorAnswer, - pub reply: tokio::sync::oneshot::Sender>, - pub delivery: OperatorCommandFence, +pub enum OperatorCommand { + Response { + interaction: crate::opensymphony_gateway_schema::approval::OperatorInteraction, + answer: crate::opensymphony_gateway_schema::approval::OperatorAnswer, + reply: tokio::sync::oneshot::Sender>, + delivery: OperatorCommandFence, + }, + HarnessOperation { + issue_identifier: String, + run_id: String, + operation_id: String, + arguments: serde_json::Value, + reply: tokio::sync::oneshot::Sender>, + delivery: OperatorCommandFence, + }, } /// The gateway's failure receipt and the run loop's scheduler mutation race @@ -2956,6 +2966,95 @@ async fn dispatch_action( Json(action): Json, ) -> impl IntoResponse { let envelope = state.store.current().await; + if action.action_kind == ActionKind::HarnessOperation { + let reject = |reason: String| { + ActionReceipt::rejected( + uuid::Uuid::new_v4().to_string(), + action.correlation_id.clone(), + action.action_kind, + reason, + ) + }; + let permission = state.action_handler.permission_for_action(&action); + if !permission.allowed { + return ( + StatusCode::FORBIDDEN, + Json( + reject("permission denied for harness operation".into()) + .with_permission(permission), + ), + ); + } + let (issue_identifier, run_id, operation_id, arguments) = + match harness_operation_action(&envelope, &action) { + Ok(operation) => operation, + Err(error) => { + let receipt = reject(error); + return (dispatch_rejection_status(&receipt), Json(receipt)); + } + }; + let Some(commands) = &state.operator_commands else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(reject("harness operation path is unavailable".into())), + ); + }; + let Ok(permit) = commands.try_reserve() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(reject("harness operation path is unavailable".into())), + ); + }; + // Reserve capacity, then validate authorization and audit before the + // runtime can observe an effect. The permit prevents a queue-full race. + let receipt = state + .action_handler + .dispatch(action.clone(), &envelope) + .await; + if receipt.status != ActionStatus::Accepted { + return (dispatch_rejection_status(&receipt), Json(receipt)); + } + let failed = |reason: String| { + let mut failure = receipt.clone(); + failure.status = ActionStatus::Rejected; + failure.reason = Some(reason); + failure.expected_followup.clear(); + failure + }; + let receipt_guard = OperatorCommandReceiptGuard(OperatorCommandFence::default()); + let (reply, received) = tokio::sync::oneshot::channel(); + permit.send(OperatorCommand::HarnessOperation { + issue_identifier, + run_id, + operation_id, + arguments, + reply, + delivery: receipt_guard.0.clone(), + }); + let (status, final_receipt) = + match tokio::time::timeout(Duration::from_secs(10), received).await { + Ok(Ok(Ok(result))) => { + let mut completed = receipt.clone(); + completed.result = Some(result); + (StatusCode::OK, completed) + } + Ok(Ok(Err(error))) => (StatusCode::CONFLICT, failed(error)), + Ok(Err(_)) => ( + StatusCode::SERVICE_UNAVAILABLE, + failed("harness operation path closed; outcome is unknown".into()), + ), + Err(_) => ( + StatusCode::GATEWAY_TIMEOUT, + failed("harness operation timed out; outcome is unknown".into()), + ), + }; + if let Err(error) = + append_harness_operation_outcome(&state.journal, &action, &final_receipt).await + { + tracing::warn!(?error, "failed to journal harness operation outcome"); + } + return (status, Json(final_receipt)); + } if matches!( action.action_kind, ActionKind::InputResponse | ActionKind::PlanDecision @@ -2990,7 +3089,7 @@ async fn dispatch_action( let receipt_guard = OperatorCommandReceiptGuard(OperatorCommandFence::default()); let (reply, received) = tokio::sync::oneshot::channel(); if commands - .try_send(OperatorCommand { + .try_send(OperatorCommand::Response { interaction, answer, reply, @@ -3027,6 +3126,106 @@ async fn dispatch_action( } } +async fn append_harness_operation_outcome( + journal: &InMemoryEventJournal, + action: &ActionDispatch, + receipt: &ActionReceipt, +) -> Result<(), JournalError> { + let kind = if receipt.status == ActionStatus::Accepted { + EventKind::GatewayActionCompleted { + action: action.action_kind.to_string(), + } + } else { + EventKind::GatewayActionFailed { + action: action.action_kind.to_string(), + reason: receipt + .reason + .clone() + .unwrap_or_else(|| "operation failed".into()), + } + }; + journal.append( + EventRecord::builder() + .actor(EventActor::system("gateway")) + .correlation_id(action.correlation_id.clone()) + .entity_ref(EntityRef { + kind: EntityKind::Run, + id: action.target_entity.entity_id.clone(), + identifier: None, + }) + .kind(kind) + .summary(format!("Harness operation {}", if receipt.status == ActionStatus::Accepted { "completed" } else { "failed" })) + .payload(serde_json::json!({ + "action_id": receipt.action_id, + "correlation_id": action.correlation_id, + "status": if receipt.status == ActionStatus::Accepted { "completed" } else { "failed" }, + "reason": receipt.reason, + })) + .build() + ).await.map(|_| ()) +} + +fn harness_operation_action( + envelope: &SnapshotEnvelope, + action: &ActionDispatch, +) -> Result<(String, String, String, serde_json::Value), String> { + if action.target_entity.entity_kind != EntityKind::Run + || action.idempotency_key.is_some() + || action.correlation_id.trim().is_empty() + { + return Err( + "harness operation requires a run target, correlation and no replay key".into(), + ); + } + let payload = action + .payload + .as_ref() + .and_then(serde_json::Value::as_object) + .ok_or("harness operation payload is missing")?; + if payload.len() != 3 + || payload + .keys() + .any(|key| !matches!(key.as_str(), "run_id" | "operation_id" | "arguments")) + { + return Err("harness operation payload contains unsupported fields".into()); + } + let run_id = payload + .get("run_id") + .and_then(serde_json::Value::as_str) + .filter(|run_id| !run_id.is_empty() && run_id.len() <= 1024) + .ok_or("run_id is missing or invalid")?; + let operation_id = payload + .get("operation_id") + .and_then(serde_json::Value::as_str) + .ok_or("operation_id is missing")?; + let arguments = payload + .get("arguments") + .filter(|arguments| { + serde_json::to_vec(arguments).is_ok_and(|bytes| bytes.len() <= 16 * 1024) + }) + .ok_or("operation arguments exceed limits")?; + let issue = find_issue_snapshot(envelope, &action.target_entity.entity_id) + .ok_or("target run not found")?; + if issue.runtime_state != ControlPlaneIssueRuntimeState::Running + || issue.harness_capability.as_ref().is_none_or(|capability| { + capability.harness != "acp" + || capability.run_binding_id.as_deref() != Some(run_id) + || !capability + .operations + .iter() + .any(|operation| operation.operation_id == operation_id) + }) + { + return Err("operation is not enabled on the active ACP run".into()); + } + Ok(( + issue.identifier.clone(), + run_id.into(), + operation_id.into(), + arguments.clone(), + )) +} + fn operator_action( envelope: &SnapshotEnvelope, action: &ActionDispatch, @@ -6123,7 +6322,7 @@ mod tests { let (commands, mut queued) = tokio::sync::mpsc::channel(1); assert!( commands - .try_send(OperatorCommand { + .try_send(OperatorCommand::Response { interaction: OperatorInteraction { request_id: "queued".into(), run_id: "run".into(), @@ -6154,12 +6353,15 @@ mod tests { .is_err() ); let command = queued.recv().await.expect("delayed command"); + let OperatorCommand::Response { + delivery, reply, .. + } = command + else { + panic!("response command"); + }; + assert!(!delivery.claim(), "a 503 fences scheduler application"); assert!( - !command.delivery.claim(), - "a 503 fences scheduler application" - ); - assert!( - command.reply.send(Ok(())).is_err(), + reply.send(Ok(())).is_err(), "failure receipt already closed" ); } diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index 297a9de2d..f38e22eb7 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -22,6 +22,9 @@ use opensymphony::opensymphony_gateway::{ use opensymphony::opensymphony_gateway_schema::action::{ ActionDispatch, ActionKind, ActionReceipt, ActionStatus, ActionTarget, }; +use opensymphony::opensymphony_gateway_schema::capability::{ + HarnessOperationCapability, HarnessProfileCapability, HarnessRunCapability, +}; use opensymphony::opensymphony_gateway_schema::code_graph::{ CodeDiffEdgeStatus, CodeDiffOverlay, CodeFileOutline, CodeGraphFreshness, CodeGraphNodeKind, CodeGraphSnapshot, CodeIndexReport, CodeIndexStatus, CodeRepoList, CodeSymbolDetail, @@ -5455,6 +5458,177 @@ async fn gateway_serves_run_approvals_with_context() { server_task.abort(); } +#[tokio::test] +async fn capabilities_endpoint_exposes_profile_operation_schema_without_wire_method() { + let operation = HarnessOperationCapability { + operation_id: "fixture.echo".into(), + namespace: "opensymphony.test".into(), + version: "1".into(), + capability_predicate: "fixture".into(), + parameters_schema: serde_json::json!({"type":"object","required":["value"]}), + result_schema: serde_json::json!({"type":"object"}), + deadline_ms: 5000, + effect: "read_only_idempotent".into(), + }; + let server = + GatewayServer::new(SnapshotStore::new(fixture_snapshot(0))).with_harness_profiles(vec![ + HarnessProfileCapability { + harness: "acp".into(), + profile_id: "fixture".into(), + preflight_ready: true, + unavailable_reason: None, + operations: vec![operation], + }, + ]); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("address"); + let server_task = tokio::spawn(async move { server.serve(listener).await.expect("serve") }); + let response: serde_json::Value = reqwest::get(format!("http://{address}/api/v1/capabilities")) + .await + .expect("capabilities") + .json() + .await + .expect("json"); + let advertised = &response["harness_profiles"][0]["operations"][0]; + assert_eq!(advertised["operation_id"], "fixture.echo"); + assert_eq!( + advertised["parameters_schema"]["required"], + serde_json::json!(["value"]) + ); + assert!(advertised.get("method").is_none()); + server_task.abort(); +} + +#[tokio::test] +async fn harness_operation_action_uses_registered_run_operation_and_result_receipt() { + let mut snapshot = fixture_snapshot(0); + snapshot.issues[0].harness_capability = Some(HarnessRunCapability { + harness: "acp".into(), + profile_id: "fixture".into(), + run_binding_id: Some("run-worker-613".into()), + protocol: "acp".into(), + protocol_version: 1, + rpc: "json_rpc_2_0".into(), + encoding: "utf-8".into(), + framing: "lf".into(), + carrier: "stdio".into(), + session_restore: false, + history_replay: false, + model_selection: false, + cancellation: true, + operator_responses: true, + operations: vec![HarnessOperationCapability { + operation_id: "fixture.echo".into(), + namespace: "opensymphony.test".into(), + version: "1".into(), + capability_predicate: "fixture".into(), + parameters_schema: serde_json::json!({"type":"object"}), + result_schema: serde_json::json!({"type":"object"}), + deadline_ms: 5000, + effect: "read_only_idempotent".into(), + }], + }); + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let journal = opensymphony::opensymphony_domain::InMemoryEventJournal::new(128, 128); + let server = GatewayServer::with_journal( + SnapshotStore::new(snapshot), + journal.clone(), + opensymphony::opensymphony_domain::StreamBroker::new(journal.clone()), + ) + .with_operator_commands(tx); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("address"); + let server_task = tokio::spawn(async move { server.serve(listener).await.expect("serve") }); + let client = reqwest::Client::new(); + let detail: serde_json::Value = client + .get(format!("http://{address}/api/v1/runs/COE-255")) + .send() + .await + .expect("run detail") + .json() + .await + .expect("run JSON"); + assert_eq!( + detail["harness_capability"]["run_binding_id"], + "run-worker-613" + ); + let action = ActionDispatch { + schema_version: Default::default(), + correlation_id: "echo-once".into(), + action_kind: ActionKind::HarnessOperation, + target_entity: ActionTarget { + entity_kind: EntityKind::Run, + entity_id: "COE-255".into(), + }, + payload: Some( + serde_json::json!({"run_id":"run-worker-613","operation_id":"fixture.echo","arguments":{"value":"hello"}}), + ), + idempotency_key: None, + }; + for (field, value) in [ + ("operation_id", serde_json::json!("unregistered")), + ("method", serde_json::json!("unsafe")), + ("run_id", serde_json::json!("stale-attempt")), + ] { + let mut invalid = action.clone(); + invalid.payload.as_mut().expect("payload")[field] = value; + let receipt: ActionReceipt = client + .post(format!("http://{address}/api/v1/actions/dispatch")) + .json(&invalid) + .send() + .await + .expect("reject") + .json() + .await + .expect("receipt"); + assert_eq!(receipt.status, ActionStatus::Rejected); + assert!(rx.try_recv().is_err()); + } + let response = tokio::spawn({ + let client = client.clone(); + let action = action.clone(); + async move { + client + .post(format!("http://{address}/api/v1/actions/dispatch")) + .json(&action) + .send() + .await + .expect("dispatch") + } + }); + let command = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv()) + .await + .expect("command timeout") + .expect("command"); + let opensymphony::opensymphony_gateway::OperatorCommand::HarnessOperation { + issue_identifier, + run_id, + operation_id, + arguments, + reply, + .. + } = command + else { + panic!("harness operation command"); + }; + assert_eq!(issue_identifier, "COE-255"); + assert_eq!(run_id, "run-worker-613"); + assert_eq!(operation_id, "fixture.echo"); + assert_eq!(arguments, serde_json::json!({"value":"hello"})); + reply + .send(Ok(serde_json::json!({"value":"hello"}))) + .expect("reply"); + let receipt: ActionReceipt = response.await.expect("task").json().await.expect("receipt"); + assert_eq!(receipt.status, ActionStatus::Accepted); + assert_eq!(receipt.result, Some(serde_json::json!({"value":"hello"}))); + let events = journal.all_events().await; + assert!(events.iter().any(|event| matches!( + event.kind, + opensymphony::opensymphony_gateway_schema::event_journal::EventKind::GatewayActionCompleted { .. } + ) && event.correlation_id.as_deref() == Some("echo-once"))); + server_task.abort(); +} + #[tokio::test] async fn acp_operator_routes_live_permission_question_and_plan_responses() { use opensymphony::opensymphony_gateway_schema::approval::{ @@ -5650,14 +5824,23 @@ async fn acp_operator_routes_live_permission_question_and_plan_responses() { .await .expect("command timeout") .expect("command"); - assert_eq!(command.interaction.request_id, interaction.request_id); + let opensymphony::opensymphony_gateway::OperatorCommand::Response { + interaction: command_interaction, + answer, + reply, + .. + } = command + else { + panic!("operator response command"); + }; + assert_eq!(command_interaction.request_id, interaction.request_id); assert!(matches!( - (&command.answer, expected), + (&answer, expected), (OperatorAnswer::Permission { .. }, "permission") | (OperatorAnswer::Question { .. }, "question") | (OperatorAnswer::Plan { .. }, "plan") )); - command.reply.send(Ok(())).expect("reply"); + reply.send(Ok(())).expect("reply"); let receipt: ActionReceipt = response .await .expect("request task") @@ -5677,8 +5860,11 @@ async fn acp_operator_routes_live_permission_question_and_plan_responses() { .await .expect("duplicate command timeout") .expect("duplicate command"); - command - .reply + let opensymphony::opensymphony_gateway::OperatorCommand::Response { reply, .. } = command + else { + panic!("operator response command"); + }; + reply .send(Err("operator request is stale or already answered".into())) .expect("duplicate rejection"); let duplicate_receipt: ActionReceipt = duplicate diff --git a/crates/opensymphony-orchestrator/src/lib.rs b/crates/opensymphony-orchestrator/src/lib.rs index 2d690e171..f9d312a23 100644 --- a/crates/opensymphony-orchestrator/src/lib.rs +++ b/crates/opensymphony-orchestrator/src/lib.rs @@ -32,10 +32,11 @@ pub use parent_integration::{ ParentTransition, ParentVerificationAttempt, parent_command_identity, }; pub use scheduler::{ - HarnessRouteDecision, OperatorResponseDelivery, RecoveredRun, RecoveryRecord, - RetryExhaustionRecord, RetryPendingRecord, Scheduler, SchedulerConfig, SchedulerError, - TrackerBackend, WorkerAbortReason, WorkerBackend, WorkerInterruptAcknowledgement, WorkerLaunch, - WorkerStartRequest, WorkerUpdate, WorkspaceBackend, decide_issue_route, + HarnessOperationDelivery, HarnessRouteDecision, OperatorResponseDelivery, RecoveredRun, + RecoveryRecord, RetryExhaustionRecord, RetryPendingRecord, Scheduler, SchedulerConfig, + SchedulerError, TrackerBackend, WorkerAbortReason, WorkerBackend, + WorkerInterruptAcknowledgement, WorkerLaunch, WorkerStartRequest, WorkerUpdate, + WorkspaceBackend, decide_issue_route, }; pub use selection::{ filter_issues_for_dispatch, issue_blocked_by_non_terminal_blockers, diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 82e252182..a85452176 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -755,6 +755,18 @@ pub trait WorkerBackend { Ok(OperatorResponseDelivery::new(async move { Ok(delivered) })) } + async fn begin_harness_operation( + &mut self, + _worker_id: &WorkerId, + _run_id: &str, + _operation_id: &str, + _arguments: serde_json::Value, + ) -> Result { + Ok(HarnessOperationDelivery::new(async { + Err("harness operations are unavailable".into()) + })) + } + async fn abort_worker( &mut self, worker_id: &WorkerId, @@ -790,6 +802,22 @@ impl OperatorResponseDelivery { } } +pub struct HarnessOperationDelivery( + Pin> + Send + 'static>>, +); + +impl HarnessOperationDelivery { + pub fn new( + wait: impl Future> + Send + 'static, + ) -> Self { + Self(Box::pin(wait)) + } + + pub async fn wait(self) -> Result { + self.0.await + } +} + #[derive(Debug, Error)] pub enum SchedulerError { #[error("invalid scheduler configuration: {detail}")] @@ -2270,6 +2298,40 @@ where self.complete_operator_response(binding, delivery.wait().await) } + /// Bind an operator action to the scheduler's current ACP worker. The + /// backend and retained owner perform the profile and peer checks. + pub async fn begin_harness_operation( + &mut self, + issue_identifier: &str, + run_id: &str, + operation_id: &str, + arguments: serde_json::Value, + ) -> Result { + let mut matching = self + .executions + .values() + .filter_map(IssueExecution::current_run) + .filter(|run| run.issue_identifier.as_str() == issue_identifier); + let run = matching.next().ok_or("operator run is not active")?; + if matching.next().is_some() + || run_id != format!("run-{}", run.worker_id) + || self + .worker_metadata + .get(&run.worker_id) + .is_none_or(|metadata| { + metadata.issue_id != run.issue_id + || metadata.harness_kind.as_deref() != Some("acp") + }) + { + return Err("operator run binding is stale or ambiguous".into()); + } + let worker_id = run.worker_id.clone(); + self.worker + .begin_harness_operation(&worker_id, run_id, operation_id, arguments) + .await + .map_err(|error| format!("ACP operation dispatch failed: {error}")) + } + /// Reserve the live decision in actor-owned state and enqueue it. Waiting /// for the harness flush happens outside the scheduler actor. pub async fn begin_operator_response( diff --git a/crates/opensymphony-tui/src/lib.rs b/crates/opensymphony-tui/src/lib.rs index e31415c55..4badf3181 100644 --- a/crates/opensymphony-tui/src/lib.rs +++ b/crates/opensymphony-tui/src/lib.rs @@ -5197,11 +5197,20 @@ mod tests { .await .expect("terminal permission timeout") .expect("terminal permission command"); + let OperatorCommand::Response { + interaction, + answer, + reply, + .. + } = command + else { + panic!("operator response command"); + }; assert!( - matches!(command.answer, OperatorAnswer::Permission { ref option_id } if option_id == "opaque-allow-11") + matches!(answer, OperatorAnswer::Permission { ref option_id } if option_id == "opaque-allow-11") ); - assert_eq!(command.interaction.rpc_id, "0"); - command.reply.send(Ok(())).expect("permission receipt"); + assert_eq!(interaction.rpc_id, "0"); + reply.send(Ok(())).expect("permission receipt"); for _ in 0..30 { app.update(AppMessage::Tick); if !app.operator_response_pending { @@ -5228,12 +5237,13 @@ mod tests { .await .expect("terminal question timeout") .expect("terminal question command"); - assert!( - matches!(command.answer, OperatorAnswer::Question { ref answers } + let OperatorCommand::Response { answer, reply, .. } = command else { + panic!("operator response command"); + }; + assert!(matches!(answer, OperatorAnswer::Question { ref answers } if answers.len() == 1 && answers[0].question_id == "region" - && answers[0].selected_option_ids == ["west-11"]) - ); - command.reply.send(Ok(())).expect("question receipt"); + && answers[0].selected_option_ids == ["west-11"])); + reply.send(Ok(())).expect("question receipt"); for _ in 0..30 { app.update(AppMessage::Tick); if !app.operator_response_pending { diff --git a/crates/opensymphony-workflow/src/acp.rs b/crates/opensymphony-workflow/src/acp.rs index 373925858..a07a952bd 100644 --- a/crates/opensymphony-workflow/src/acp.rs +++ b/crates/opensymphony-workflow/src/acp.rs @@ -171,9 +171,20 @@ impl AcpProfile { "session selections must be bounded nonempty opaque identifiers", )); } - if !self.extensions.is_empty() { + if self.extensions.len() > 8 + || self + .extensions + .iter() + .any(|id| !matches!(id.as_str(), "cursor@2026.09.08-6caf4ff" | "fixture_echo@1")) + || self + .extensions + .iter() + .collect::>() + .len() + != self.extensions.len() + { return Err(invalid( - "no extension handlers are implemented; extensions must be empty", + "extensions must name distinct supported contract versions", )); } if self.required_capabilities.iter().any(|c| { diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index ff8b223be..b00a91e47 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -1267,6 +1267,9 @@ pub struct AcpSessionState { pub session_id: Option, /// Negotiated initialization metadata, redacted by the protocol client. pub initialization: serde_json::Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enabled_operations: + Vec, /// Whether this session advertised a selectable model configuration option. #[serde(default)] pub model_selection: bool, diff --git a/docs/acp-extension-evidence.md b/docs/acp-extension-evidence.md new file mode 100644 index 000000000..395d14c1a --- /dev/null +++ b/docs/acp-extension-evidence.md @@ -0,0 +1,77 @@ +# ACP extension qualification evidence + +## Pinned Cursor CLI + +The authenticated Cursor CLI `2026.09.08-6caf4ff` was invoked as `agent acp` +from disposable issue workspaces on 2026-09-25 UTC. Initialization negotiated +ACP protocol version 1; `authenticate` with the advertised `cursor_login` method +and `session/new` succeeded. The probes requested chat-only color answers and +disabled client filesystem and terminal capabilities. No credentials, account +identifiers, absolute workspace paths, or raw session IDs are retained here. + +Plan mode emitted an ID-bearing `cursor/create_plan` **request**. It had no +`params.sessionId`; the active ACP session supplied the binding. Its observed +envelope and the accepted and rejected result shapes are: + +```json +{"jsonrpc":"2.0","id":0,"method":"cursor/create_plan","params":{"toolCallId":"","name":"Chat Color Answer","overview":"","plan":"","todos":[{"id":"chat-answer","content":"","status":"pending"}],"isProject":false,"phases":[]}} +{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"accepted"}}} +{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"rejected"}}} +``` + +The two result lines represent separate safe sessions, not duplicate responses +to one request. Both results let the plan prompt finish normally. In agent mode +the CLI emitted `cursor/update_todos` with numeric +ID `0` and no `params.sessionId`. The host must answer this request even though +Cursor's public documentation describes todo updates as notifications. The +observed envelope and accepted response are: + +```json +{"jsonrpc":"2.0","id":0,"method":"cursor/update_todos","params":{"toolCallId":"","todos":[{"id":"color-probe","content":"Answer the color probe","status":"completed"}],"merge":false}} +{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"accepted","todos":[{"id":"color-probe","content":"Answer the color probe","status":"completed"}]}}} +{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"rejected"}}} +``` + +The accepted and rejected todo results came from separate safe sessions; both +ended with `stopReason: end_turn`. The pinned CLI completed the todo prompt even +when a separate probe delayed its response four seconds. The JSON-RPC ID still makes the frame a request, so the +production host validates and responds. The runtime owner, rather than a +client-supplied session field, binds both methods to the active ACP session. +Unqualified `cursor/ask_question`, `cursor/task`, and `cursor/generate_image` +methods are not enabled by this registration. A no-ID Cursor notification was +not observed; deterministic fixtures cannot establish its pinned live behavior. + +## Production path and deterministic regressions + +The manual authenticated integration tests run the actual pinned CLI through +`SessionHost`, not the standalone probe. The plan test received the real ID `0` +request without a session field, opened a `PlanApproval` interaction bound to +the host-owned session, delivered an operator rejection, observed the exact +rejected result, and completed the prompt. The todo test received the real ID +`0` request without a session field, sent the accepted result with the todo +list, and completed the prompt. Invalid or unbound todo requests receive the +observed rejected result shape. Scheduler todo activity is projected only from +the correlated accepted response; a rejected request creates no todo update. +Duplicate in-flight IDs remain ambiguous until every matching response drains, +including collisions between plan, todo, and standard callback methods. +At 16 unresolved todo requests, another request emits a bounded saturation +diagnostic and fences the worker rather than silently dropping accepted activity. +Both use disposable issue workspaces, are +ignored in unauthenticated CI, and passed when invoked manually: + +```text +OPENSYMPHONY_CURSOR_AGENT_BIN=/path/to/pinned/agent cargo test-system-duckdb --test acp_session_host pinned_cursor_live -- --ignored --nocapture + 2 passed; 0 failed; finished in 14.85s +``` + +`tests/fixtures/acp_session_peer.py` gives repeatable plan and todo ID `0` +callbacks without `sessionId`, plus a mismatched-session plan and unknown +method. A simulated no-ID frame verifies the generic no-response rule without +claiming live Cursor notification support. The host fixture verifies operator +routing, plan acceptance, todo response, and safe rejection. The fixture echo peer advertises an explicit +capability and verifies the owner-supplied session ID, permitted `_meta`, +structured result, invalid arguments, and a timeout that reports outcome +unknown without retry. A 24-trial regression verifies that prompt completion +does not discard a correlated operation response. A synthetic credential in +the result value and metadata is redacted before the public operation result; +successive timed-out batches remain inside the eight unresolved-request limit. diff --git a/docs/architecture.md b/docs/architecture.md index 33cb38438..f85713135 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -533,6 +533,28 @@ advertisements. The ordered dispatch handler admits callbacks to one bounded connection-owned actor. File operations are serialized; terminal waits use bounded asynchronous responses so they cannot block RPC dispatch. Terminal processes use the existing process-group or Windows Job Object supervisors. +ACP extension registrations are exact profile/version entries inside the ACP +module. The pinned Cursor `cursor/create_plan` request uses the scheduler-owned +plan response path; its ID-bearing `cursor/update_todos` request receives a +bounded response and contributes todo activity only after its correlated +accepted response. The response correlator tracks IDs across all inbound +callback methods, so a plan response cannot accept a concurrent same-ID todo. +A bounded pending-candidate queue fences the worker with a +visible diagnostic on saturation. Both bind to the connection-owned +active session without a peer-supplied session field. Unobserved Cursor question, +task, and image methods are outside the enabled registration. Outbound operation dispatch enters +through the gateway's operator action, binds the current run in the scheduler, +and resolves the registered method and session inside the retained owner. The +public run capability supplies its attempt binding; the host bounds concurrent +requests and the gateway journals each accepted operation's outcome. An outbound +RPC retains its own ID and deadline after prompt completion so a back-to-back +operation result is not discarded by the prompt callback epoch. +Negotiated peer support and profile enablement are both required. Timeouts +report an unknown outcome, while the unresolved SDK waiter retains its permit +until a peer response or connection closure. This caps pending replies at eight +even across successive timeout batches. Known-secret redaction runs on the +validated result before it can enter an operator receipt. Lifecycle state +remains orchestrator-owned. Each retained prompt first retires the prior callback epoch through a bounded, cancellable preparation step while the owner continues servicing commands. Only then does it persist submission and dispatch the prompt. Session config responses diff --git a/docs/configuration.md b/docs/configuration.md index 982f3529e..271c3b5cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1018,12 +1018,20 @@ The selected authentication method must be an advertised agent-handled method; terminal/browser authentication is not advertised. Omit `auth` for agents with existing login state that need no `authenticate` call. -Profiles reject cwd overrides, unsupported wire versions/transports, extension -handlers, credential arguments regardless of flag casing, and invalid environment +Profiles reject cwd overrides, unsupported wire versions/transports, unregistered +extensions, credential arguments regardless of flag casing, and invalid environment references. Optional `required_capabilities` supports `prompt.image`, `prompt.audio`, and `prompt.embedded_context`; each requirement is checked before session creation, and a failed check names the missing capability. +`extensions` accepts `fixture_echo@1` for executable outbound tests and +`cursor@2026.09.08-6caf4ff` for the pinned CLI's observed plan approval and +todo request contracts. Cursor callbacks bind to the connection-owned active +session even when the peer omits `sessionId`. The registration does not enable +unobserved question, task, or image methods. Duplicate IDs and unspecified +vendor versions fail validation. +Registration alone grants no operation: outbound use also requires the peer's +capability predicate and an active, bound run. The prompt API sends text. Explicit session selections live in each profile: ```yaml @@ -1064,6 +1072,18 @@ stderr. The peer receives the exact host-supplied attachment. ACP operator responses use the scheduler-owned pending request path; arbitrary vendor extensions remain separately gated. +The gateway publishes each registered outbound operation's parameter and +result schemas, version, capability predicate, deadline, and effect policy in +profile and negotiated run capabilities. Clients invoke one through +`POST /api/v1/actions/dispatch` with `action_kind: harness_operation`, a run +target, and exactly `run_id`, `operation_id`, and `arguments` in the payload. +Use `harness_capability.run_binding_id` from the current run detail or snapshot +as `run_id`; the gateway and scheduler reject stale attempts. The owner resolves +the wire method and session ID. At most eight outbound operations are outstanding +per ACP session, and completion or failure is published to the event journal +with the action correlation ID. The action rejects an +idempotency key so replay cannot silently repeat an uncertain operation. + ACP credential arguments such as `--access-token`, `--oauth2-bearer`, `--client-secret`, and `--pat` are rejected in separate-value and equals forms, including mixed case; credentials belong in `env_refs`. A top-level `acp` section diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 95fdf38f9..cdada5592 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -168,7 +168,8 @@ cleanup until execution risk is reconciled. | MCP | Required host-scoped stdio attachments; HTTP/SSE only after agent negotiation. | | Permission | Offered opaque choices, `operator`/`deny`/`allow_once` policy, deadline and cancellation. | | Structured question | Standard `elicitation/create` required string-enum and string-array-enum choice forms through gateway inputs, with accept, decline and cancel. | -| Plan approval | Typed gateway and client control; vendor method registration is handled by COE-613. | +| Plan approval | Typed gateway and client control; the authenticated pinned Cursor `cursor/create_plan` request routes through the operator response path. | +| ACP extensions | Exact version/profile registration for observed pinned Cursor plan and ID-bearing todo requests, peer-gated outbound `fixture.echo`, and advertised operation schemas and attempt binding on profile and run capabilities. Cursor question, task, image, and no-ID notification behavior remains unqualified. | | Interactive auth and elicitation | Form-only capability is advertised; free-text, numeric, optional, constrained, request-scoped and URL elicitation are unavailable. | The production worker enables file and terminal callbacks for its verified diff --git a/docs/operations.md b/docs/operations.md index 810ac0e13..c459bec0e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1045,7 +1045,8 @@ choices per field. Unsupported constraints, free-text fields, URL mode, and secret prompts are rejected without exposing them in public snapshots. Web and desktop Run Detail panels use offered permission option IDs and multiple-choice form controls. Plan approval controls are typed for a registered extension; -Cursor method registration is handled separately. FrankenTUI shows a pending +The authenticated pinned Cursor `cursor/create_plan` request uses the plan +control when the profile enables its exact version registration. FrankenTUI shows a pending request at the top of Issue Detail: `o` cycles requests, `,` and `.` page through offered options, `1`–`4` selects a visible option, `[` and `]` cycle questions, `s` submits complete answers, and `x` cancels. Question `n` declines. @@ -1082,6 +1083,34 @@ Only requests accepted into the scheduler's live pending set emit waiting activity. Web and desktop retain selected form choices across live refreshes while the same request remains pending. +Registered ACP harness operations appear in profile and active run capabilities. +An operator invokes `harness_operation` through the gateway with a run target +and advertised operation ID. The payload `run_id` is the current +`harness_capability.run_binding_id` in the run detail or snapshot. The service +binds the current run and session and +validates arguments before sending a structured result receipt. Permission +failure, stale binding, absent peer capability, invalid arguments, and disabled +registration reject the action. `fixture.echo` is read-only and available only +to `fixture_echo@1` profiles when the peer advertises +`opensymphony.dev/fixtureEcho: 1`; it exists for executable contract tests. +A deadline or disconnect after dispatch reports an unknown outcome, requiring +evidence inspection before any repeat. The event journal emits a correlated +completion or failure for each accepted dispatch. The pinned Cursor profile +supports observed plan approvals and ID-bearing todo requests; question, task, +image, and no-ID notification paths remain unqualified. The captured wire +contract and production test are in +[acp-extension-evidence.md](acp-extension-evidence.md). +Todo activity follows the correlated accepted callback result. Duplicate +in-flight peer IDs across plan, todo, and standard callbacks remain ambiguous +until all matching responses drain. More than 16 unresolved todo requests or +128 concurrent callback IDs emits a diagnostic and fences the worker. +The outbound operation's response can complete after the prompt finishes; +prompt completion does not cancel its separately bounded RPC wait. A deadline +returns outcome unknown to the caller while keeping that unresolved SDK request +inside the eight-operation limit until its reply or connection closure. Peer +results are redacted with the session's known secrets before entering the public +action receipt, including strings in `value` and `_meta`. + ## Current model diff --git a/docs/specs/acp-harness-adapter.md b/docs/specs/acp-harness-adapter.md index d5a460615..ca021996c 100644 --- a/docs/specs/acp-harness-adapter.md +++ b/docs/specs/acp-harness-adapter.md @@ -346,19 +346,20 @@ Lifecycle-changing extensions still report through scheduler-owned commands. ### Concrete vendor handling -Cursor is a useful first extension qualification target. Its documentation lists -blocking `cursor/ask_question` and `cursor/create_plan`, plus notification methods -for todos, tasks, and generated images. The documented names omit the standard -extension prefix. Support those exact names through a Cursor-specific -compatibility registration after checking captured frames; do not rename their -wire methods or weaken generic method validation. Classify each actual frame by -the presence of an RPC ID, including `0`, because request and notification -behavior must follow the wire envelope. +Cursor is the first qualified extension. Its documentation lists +`cursor/ask_question` and `cursor/create_plan` as blocking methods and describes +todo, task, and generated-image methods as notifications. The authenticated +pinned CLI instead emitted ID-bearing `cursor/create_plan` and +`cursor/update_todos` requests without `params.sessionId`. The exact-version +registration enables those observed methods and binds them to the runtime-owned +session; question, task, image, and no-ID notification paths remain unqualified. +Classify each actual frame by the presence of an RPC ID, including `0`, because +request and notification behavior must follow the wire envelope. [Cursor ACP](https://cursor.com/docs/cli/acp). -Map questions to input requests and plans to plan approval; map known -notifications to existing activity/artifact projections. A received subagent -notification is evidence about work, not authority to start an OpenSymphony issue. +Map the qualified plan request to plan approval and the qualified todo request +to bounded todo activity. A received subagent notification is evidence about +work, not authority to start an OpenSymphony issue. Generic unknown-request handling must never hang the agent or auto-approve it. Devin CLI should be another profile of the same `acp` adapter. Keep its identity diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index 1071e53c1..ec4f99a00 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -659,6 +659,25 @@ cancel responses, private large numeric peer RPC IDs with generated public binding tokens, disabled Cursor methods, and form-only capability advertisement. The production scheduler/gateway fixture routes a permission and a choice form through HTTP action receipts to the blocked peer. +The extension peer verifies a registered outbound request has the host-bound +session ID and permitted `_meta`, returns a structured result, and rejects +unregistered methods, stale runs, and unexpected argument keys. The peer sends +the operation result immediately before prompt completion; a repeated host +regression verifies all correlated results survive that ordering. Cursor +unit and host fixture tests cover plan and todo request ID `0` without a +peer-supplied session ID, the accepted plan and todo result shapes, mismatched +session cancellation, owner-bound accepted-response todo projection, duplicate +ID ambiguity across plan/todo callbacks, visible saturation failure, and +unknown request behavior. +Two separately ignored manual integration tests exercise the authenticated +pinned CLI through the production `SessionHost` plan operator route and todo +response path. These tests require local CLI authentication and are not CI gates. +The host integration suite also checks that a peer-echoed environment credential +is redacted in both the result value and metadata, and that a second batch of +operations cannot bypass the eight pending-reply limit after the first batch +times out. +The redacted wire frames and production-path qualification are recorded in +[ACP extension qualification evidence](acp-extension-evidence.md). It uses a five-minute tracker interval and processes both callbacks through worker-update wakeups without another tracker tick. An un-routed native form receives protocol cancellation without failing its diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 41f5106a0..5c0dcd6f8 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -942,6 +942,10 @@ credential/grant revision, exact workspace/repository/checkout binding, opaque session ID, run/attempt, connection generation, negotiated capabilities and submission/outcome state. Native manifests remain readable and cannot be silently replaced by ACP. Every command checks the owning connection generation. +The negotiated ACP record also stores enabled outbound operation descriptors. +Recovery projects those descriptors for inspection, while a new invocation +still requires the live owner, current run, exact profile registration, and +peer capability check; a durable descriptor alone grants no execution right. The manifest is atomically replaced and synced, including its parent directory, before a prompt can reach the transport. Cancellation after the synced diff --git a/packages/gateway-schema/__tests__/capability.test.ts b/packages/gateway-schema/__tests__/capability.test.ts index 15479af45..029fa4f42 100644 --- a/packages/gateway-schema/__tests__/capability.test.ts +++ b/packages/gateway-schema/__tests__/capability.test.ts @@ -10,6 +10,9 @@ test("Rust and TypeScript share ACP profile and negotiated run projections", () const negotiated: boolean = fixture.run.session_restore; expect(ready).toBe(false); expect(negotiated).toBe(true); + expect(fixture.profile.operations?.[0].operation_id).toBe("fixture.echo"); + expect(fixture.run.operations?.[0].deadline_ms).toBe(5000); + expect(fixture.run.run_binding_id).toBe("run-worker-613"); expect(fixture.run).not.toHaveProperty("input_tokens"); expect(fixture.profile).not.toHaveProperty("command"); expect(JSON.parse(JSON.stringify(fixture))).toEqual(fixture); diff --git a/packages/gateway-schema/__tests__/fixtures/acp-capability.json b/packages/gateway-schema/__tests__/fixtures/acp-capability.json index a8a2dc7f2..76ff54237 100644 --- a/packages/gateway-schema/__tests__/fixtures/acp-capability.json +++ b/packages/gateway-schema/__tests__/fixtures/acp-capability.json @@ -3,11 +3,46 @@ "harness": "acp", "profile_id": "local", "preflight_ready": false, - "unavailable_reason": "credential_reference_unavailable" + "unavailable_reason": "credential_reference_unavailable", + "operations": [ + { + "operation_id": "fixture.echo", + "namespace": "opensymphony.test", + "version": "1", + "capability_predicate": "agentCapabilities._meta['opensymphony.dev/fixtureEcho'] == 1", + "parameters_schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "maxLength": 4096 + } + } + }, + "result_schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "maxLength": 4096 + } + } + }, + "deadline_ms": 5000, + "effect": "read_only_idempotent" + } + ] }, "run": { "harness": "acp", "profile_id": "local", + "run_binding_id": "run-worker-613", "protocol": "acp", "protocol_version": 1, "rpc": "json_rpc_2_0", @@ -18,6 +53,40 @@ "history_replay": false, "model_selection": false, "cancellation": true, - "operator_responses": false + "operator_responses": false, + "operations": [ + { + "operation_id": "fixture.echo", + "namespace": "opensymphony.test", + "version": "1", + "capability_predicate": "agentCapabilities._meta['opensymphony.dev/fixtureEcho'] == 1", + "parameters_schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "maxLength": 4096 + } + } + }, + "result_schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "maxLength": 4096 + } + } + }, + "deadline_ms": 5000, + "effect": "read_only_idempotent" + } + ] } } diff --git a/packages/gateway-schema/src/action.ts b/packages/gateway-schema/src/action.ts index 5d8781235..cb1462522 100644 --- a/packages/gateway-schema/src/action.ts +++ b/packages/gateway-schema/src/action.ts @@ -16,6 +16,7 @@ export type ActionKind = | "approval_decision" | "input_response" | "plan_decision" + | "harness_operation" | "publish_plan" | "task_graph_milestone" | "task_graph_issue" diff --git a/packages/gateway-schema/src/capability.ts b/packages/gateway-schema/src/capability.ts index 75f095525..c55e70d1c 100644 --- a/packages/gateway-schema/src/capability.ts +++ b/packages/gateway-schema/src/capability.ts @@ -85,11 +85,24 @@ export interface HarnessProfileCapability { profile_id: string; preflight_ready: boolean; unavailable_reason: string | null; + operations?: HarnessOperationCapability[]; +} + +export interface HarnessOperationCapability { + operation_id: string; + namespace: string; + version: string; + capability_predicate: string; + parameters_schema: unknown; + result_schema: unknown; + deadline_ms: number; + effect: string; } export interface HarnessRunCapability { harness: string; profile_id: string; + run_binding_id?: string | null; protocol: string; protocol_version: number; rpc: string; @@ -101,4 +114,5 @@ export interface HarnessRunCapability { model_selection: boolean; cancellation: boolean; operator_responses: boolean; + operations?: HarnessOperationCapability[]; } diff --git a/packages/gateway-schema/src/index.ts b/packages/gateway-schema/src/index.ts index 9feebf51b..910734c34 100644 --- a/packages/gateway-schema/src/index.ts +++ b/packages/gateway-schema/src/index.ts @@ -170,7 +170,7 @@ export type { export type { PlanningArtifactKind, PlanningArtifact, PlanningSessionStatus, PlanningSessionSummary } from "./planning.js"; // capability -export type { AuthMode, TransportCapability, FeatureCapability, GatewayCapabilities, HarnessCapability, HarnessProfileCapability, HarnessRunCapability } from "./capability.js"; +export type { AuthMode, TransportCapability, FeatureCapability, GatewayCapabilities, HarnessCapability, HarnessProfileCapability, HarnessOperationCapability, HarnessRunCapability } from "./capability.js"; export { authStateFromError } from "./auth.js"; export type { AuthState, AuthErrorCode } from "./auth.js"; diff --git a/tests/acp.rs b/tests/acp.rs index 7ae6a0d6f..797d45a87 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -566,6 +566,18 @@ fn acp_workflow_profiles_validate_and_preserve_environment_references() { let rendered = serde_yaml::to_string(&resolved.extensions.acp).expect("render"); assert!(rendered.contains("AUTH_SOURCE")); assert!(!rendered.contains("auth-secret")); + let configured = WorkflowDefinition::parse(&source.replace( + "args: [agent.py]", + "args: [agent.py]\n extensions: [fixture_echo@1]", + )) + .expect("versioned ACP profile"); + assert!(configured.resolve(Path::new("/repo"), &env).is_ok()); + let qualified_cursor = WorkflowDefinition::parse(&source.replace( + "args: [agent.py]", + "args: [agent.py]\n extensions: [cursor@2026.09.08-6caf4ff]", + )) + .expect("versioned Cursor profile"); + assert!(qualified_cursor.resolve(Path::new("/repo"), &env).is_ok()); for (old, new) in [ ("harness_profile: fake", "harness_profile: missing"), ("args: [agent.py]", "args: ['--TOKEN=secret']"), @@ -575,6 +587,10 @@ fn acp_workflow_profiles_validate_and_preserve_environment_references() { ("args: [agent.py]", "transport: http"), ("args: [agent.py]", "protocol_versions: [2]"), ("args: [agent.py]", "extensions: [cursor]"), + ( + "args: [agent.py]", + "extensions: [cursor@2026.09.08-unsupported]", + ), ("args: [agent.py]", "required_capabilities: [unknown]"), ("AUTH_SOURCE", "literal-secret-value"), ("args: [agent.py]", "auth: {method_id: ''}"), @@ -1230,6 +1246,12 @@ fn acp_adapter_exposes_execution_and_explicit_gaps() { assert!(capability.actions.approve && capability.approvals.human_decision); assert!(!capability.pause_resume.resume); assert!(!capability.feature_gaps.is_empty()); + let public = serde_json::to_value(capability).expect("public adapter capability"); + assert_eq!(public["kind"], "acp"); + assert!( + public.get("method").is_none(), + "wire methods stay inside ACP" + ); } fn services_profile(mode: &str) -> AcpProfile { diff --git a/tests/acp_session_host.rs b/tests/acp_session_host.rs index 75e86c3cc..b18d1642d 100644 --- a/tests/acp_session_host.rs +++ b/tests/acp_session_host.rs @@ -1,7 +1,8 @@ +use futures_util::future::join_all; use opensymphony::opensymphony_gateway_schema::approval::{ OperatorAnswer, OperatorInteractionKind, OperatorQuestionAnswer, }; -use opensymphony::opensymphony_workflow::AcpPermissionPolicy; +use opensymphony::opensymphony_workflow::{AcpAuth, AcpPermissionPolicy}; use opensymphony::{opensymphony_acp::*, opensymphony_workspace::*}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -128,6 +129,574 @@ async fn retire(handle: &SessionHandle) { .await .expect("quiescent retirement"); } + +#[tokio::test] +async fn registered_outbound_operation_binds_session_and_preserves_metadata() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "EXTENSION-ECHO", "extension_echo").await; + request.profile.extensions.push("fixture_echo@1".into()); + let handle = host.open(request).await.expect("open"); + let initial = handle.inspect().await.expect("inspect"); + assert_eq!(initial.state.enabled_operations.len(), 1); + let handle_for_prompt = handle.clone(); + let prompt = tokio::spawn(async move { + handle_for_prompt + .prompt( + "echo-run".into(), + 1, + "extension-echo".into(), + CancellationToken::new(), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(3), async { + while !root.path().join("EXTENSION-ECHO/extension-ready").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("peer ready"); + assert!(matches!( + handle + .operation( + "wrong-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello"}) + ) + .await, + Err(HostError::IdentityMismatch) + )); + assert!( + handle + .operation( + "echo-run".into(), + "other".into(), + serde_json::json!({"value":"hello"}) + ) + .await + .is_err() + ); + assert!( + handle + .operation( + "echo-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello","method":"unsafe"}) + ) + .await + .is_err() + ); + let result = handle + .operation( + "echo-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello","_meta":{"traceparent":"trace-echo"}}), + ) + .await + .expect("registered operation"); + assert_eq!( + result, + serde_json::json!({"value":"hello","_meta":{"traceparent":"trace-echo"}}) + ); + assert!( + prompt + .await + .expect("prompt task") + .expect("prompt") + .succeeded() + ); + retire(&handle).await; +} + +#[tokio::test] +async fn outbound_response_preceding_prompt_completion_is_never_lost() { + // The peer writes the operation result and prompt completion back-to-back. + // Repetition catches scheduling differences between SDK waiter delivery + // and the retained host's prompt-completion branch. + for trial in 0..24 { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "EXTENSION-RACE", "extension_echo").await; + request.profile.extensions.push("fixture_echo@1".into()); + let handle = host.open(request).await.expect("open"); + let prompt_handle = handle.clone(); + let prompt = tokio::spawn(async move { + prompt_handle + .prompt( + "echo-race-run".into(), + 1, + "extension-echo".into(), + CancellationToken::new(), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(3), async { + while !root.path().join("EXTENSION-RACE/extension-ready").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("peer ready"); + let result = handle + .operation( + "echo-race-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello","_meta":{"traceparent":"trace-echo"}}), + ) + .await + .unwrap_or_else(|error| { + panic!("trial {trial} lost correlated operation result: {error}") + }); + assert_eq!(result["value"], "hello", "trial {trial}"); + assert!( + prompt + .await + .expect("prompt task") + .expect("prompt") + .succeeded() + ); + retire(&handle).await; + } +} + +#[tokio::test] +async fn outbound_operation_redacts_known_secrets_in_value_and_metadata() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "EXTENSION-SECRET", "extension_echo_secret").await; + request.profile.extensions.push("fixture_echo@1".into()); + request.context.environment.insert( + "ACCESS_TOKEN".into(), + "synthetic-operation-secret-613".into(), + ); + let handle = host.open(request).await.expect("open"); + let prompt_handle = handle.clone(); + let prompt = tokio::spawn(async move { + prompt_handle + .prompt( + "echo-secret-run".into(), + 1, + "extension-echo".into(), + CancellationToken::new(), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(3), async { + while !root + .path() + .join("EXTENSION-SECRET/extension-ready") + .exists() + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("peer ready"); + let result = handle + .operation( + "echo-secret-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello","_meta":{"traceparent":"trace-echo"}}), + ) + .await + .expect("registered operation"); + assert_eq!( + result, + serde_json::json!({"value":"[redacted]","_meta":{"traceparent":"[redacted]"}}) + ); + assert!( + !serde_json::to_string(&result) + .expect("result") + .contains("synthetic-operation-secret-613") + ); + assert!( + prompt + .await + .expect("prompt task") + .expect("prompt") + .succeeded() + ); + retire(&handle).await; +} + +#[tokio::test] +async fn outbound_operation_timeout_reports_unknown_without_retry() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "EXTENSION-TIMEOUT", "extension_echo_timeout").await; + request.profile.extensions.push("fixture_echo@1".into()); + request.limits.prompt_timeout = Duration::from_secs(8); + let handle = host.open(request).await.expect("open"); + let cancellation = CancellationToken::new(); + let handle_for_prompt = handle.clone(); + let prompt_cancel = cancellation.clone(); + let prompt = tokio::spawn(async move { + handle_for_prompt + .prompt( + "echo-timeout-run".into(), + 1, + "extension-echo".into(), + prompt_cancel, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(3), async { + while !root + .path() + .join("EXTENSION-TIMEOUT/extension-ready") + .exists() + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("peer ready"); + let result = handle + .operation( + "echo-timeout-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello","_meta":{"traceparent":"trace-echo"}}), + ) + .await; + assert!( + matches!(result, Err(HostError::Client(message)) if message.contains("outcome is unknown")) + ); + let count = std::fs::read_to_string( + root.path() + .join("EXTENSION-TIMEOUT/extension-request-count"), + ) + .expect("request marker"); + assert_eq!( + count.lines().count(), + 1, + "deadline must not retry a request" + ); + cancellation.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(3), prompt) + .await + .expect("prompt completion"); +} + +#[tokio::test] +async fn outbound_operation_inflight_limit_rejects_ninth_request() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "EXTENSION-LIMIT", "extension_echo_timeout").await; + request.profile.extensions.push("fixture_echo@1".into()); + request.limits.prompt_timeout = Duration::from_secs(8); + let handle = host.open(request).await.expect("open"); + let cancellation = CancellationToken::new(); + let prompt_handle = handle.clone(); + let prompt_cancel = cancellation.clone(); + let prompt = tokio::spawn(async move { + prompt_handle + .prompt( + "echo-limit-run".into(), + 1, + "extension-echo".into(), + prompt_cancel, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(3), async { + while !root.path().join("EXTENSION-LIMIT/extension-ready").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("peer ready"); + let results = join_all((0..9).map(|_| { + handle.operation( + "echo-limit-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello","_meta":{"traceparent":"trace-echo"}}), + ) + })) + .await; + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(HostError::ResourceLimit))) + .count(), + 1 + ); + assert_eq!(results.iter().filter(|result| matches!(result, Err(HostError::Client(message)) if message.contains("outcome is unknown"))).count(), 8); + let count = + std::fs::read_to_string(root.path().join("EXTENSION-LIMIT/extension-request-count")) + .expect("request marker"); + assert_eq!(count.lines().count(), 8); + let later = join_all((0..8).map(|_| { + handle.operation( + "echo-limit-run".into(), + "fixture.echo".into(), + serde_json::json!({"value":"hello","_meta":{"traceparent":"trace-echo"}}), + ) + })) + .await; + assert!( + later + .iter() + .all(|result| matches!(result, Err(HostError::ResourceLimit))) + ); + let count = + std::fs::read_to_string(root.path().join("EXTENSION-LIMIT/extension-request-count")) + .expect("request marker"); + assert_eq!( + count.lines().count(), + 8, + "timed-out SDK replies still consume permits" + ); + cancellation.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(3), prompt) + .await + .expect("prompt completion"); +} + +#[tokio::test] +async fn cursor_request_id_zero_uses_vendor_plan_and_todo_results() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "CURSOR-EXTENSION", "none").await; + request + .profile + .extensions + .push("cursor@2026.09.08-6caf4ff".into()); + let handle = host.open(request).await.expect("open"); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + let handle_for_prompt = handle.clone(); + let prompt = tokio::spawn(async move { + handle_for_prompt + .prompt_with_operator( + "cursor-run".into(), + 1, + "cursor-extension".into(), + CancellationToken::new(), + Some(tx), + ) + .await + }); + let opened = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(AcpOperatorEvent::Opened(opened)) = rx.recv().await { + break opened; + } + } + }) + .await + .expect("operator event"); + assert_eq!( + opened.interaction.kind, + OperatorInteractionKind::PlanApproval + ); + let (acknowledgement, delivered) = tokio::sync::oneshot::channel(); + assert!( + opened + .reply + .send(AcpOperatorReply { + answer: OperatorAnswer::Plan { accepted: true }, + acknowledgement, + delivery: AcpOperatorDeliveryFence::default(), + }) + .is_ok() + ); + assert!(delivered.await.expect("acknowledged")); + assert!( + prompt + .await + .expect("prompt task") + .expect("prompt") + .succeeded() + ); + let methods = + std::fs::read_to_string(root.path().join("CURSOR-EXTENSION/methods")).expect("method log"); + assert!(!methods.contains("None")); + retire(&handle).await; +} + +// Manual qualification only: CI has no authenticated Cursor CLI. The fixture +// above remains the deterministic contract regression. +fn pinned_cursor_binary() -> String { + let binary = std::env::var("OPENSYMPHONY_CURSOR_AGENT_BIN") + .expect("set OPENSYMPHONY_CURSOR_AGENT_BIN to the authenticated pinned CLI"); + let version = std::process::Command::new(&binary) + .arg("--version") + .output() + .expect("Cursor CLI version"); + assert!(version.status.success()); + assert!(String::from_utf8_lossy(&version.stdout).contains("2026.09.08-6caf4ff")); + binary +} + +#[tokio::test] +#[ignore = "requires authenticated pinned Cursor CLI"] +async fn pinned_cursor_live_plan_uses_production_operator_route() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "CURSOR-LIVE-PLAN", "none").await; + request.profile.command = pinned_cursor_binary(); + request.profile.args = vec!["acp".into()]; + request.profile.auth = Some(AcpAuth { + method_id: "cursor_login".into(), + }); + request + .profile + .extensions + .push("cursor@2026.09.08-6caf4ff".into()); + request.profile.session.mode = Some("plan".into()); + request + .context + .environment + .insert("HOME".into(), std::env::var("HOME").expect("HOME")); + request.limits.setup_timeout = Duration::from_secs(30); + request.limits.prompt_timeout = Duration::from_secs(150); + request.limits.callback_timeout = Duration::from_secs(90); + // A long plan streams many chunks after the callback; keep its source + // frame in the manual test's bounded history until final assertions. + request.limits.queued_frames = 1024; + request.limits.queued_bytes = 8 * 1024 * 1024; + request.limits.evidence_frames = 1024; + request.limits.evidence_bytes = 8 * 1024 * 1024; + let handle = host.open(request).await.expect("open authenticated CLI"); + let owner_session = handle + .inspect() + .await + .expect("inspect") + .state + .session_id + .expect("session"); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + let handle_for_prompt = handle.clone(); + let prompt = tokio::spawn(async move { + handle_for_prompt.prompt_with_operator( + "cursor-live-plan".into(), 1, + "Read-only ACP protocol test. Create a plan for answering a color-choice question entirely in chat. Present it through your CreatePlan tool for operator approval and wait for the result. Do not inspect or modify files, run commands, browse, or use MCP.".into(), + CancellationToken::new(), Some(tx), + ).await + }); + let opened = tokio::time::timeout(Duration::from_secs(90), async { + loop { + if let Some(AcpOperatorEvent::Opened(opened)) = rx.recv().await { + break opened; + } + } + }) + .await + .expect("real plan callback"); + assert_eq!( + opened.interaction.kind, + OperatorInteractionKind::PlanApproval + ); + assert_eq!(opened.interaction.session_id, owner_session); + let (acknowledgement, delivered) = tokio::sync::oneshot::channel(); + assert!( + opened + .reply + .send(AcpOperatorReply { + answer: OperatorAnswer::Plan { accepted: false }, + acknowledgement, + delivery: AcpOperatorDeliveryFence::default(), + }) + .is_ok() + ); + assert!(delivered.await.expect("response delivered")); + assert!( + prompt + .await + .expect("prompt task") + .expect("prompt") + .succeeded() + ); + let frames: Vec<_> = handle + .source_history() + .events + .into_iter() + .filter_map(|event| { + if let SessionEvent::Source { frame, .. } = event { + Some(frame) + } else { + None + } + }) + .collect(); + assert!(frames.iter().any(|frame| frame.direction == "incoming" + && frame.payload["method"] == "cursor/create_plan" + && frame.payload["id"] == 0 + && frame.payload["params"].get("sessionId").is_none())); + assert!(frames.iter().any(|frame| frame.direction == "outgoing" + && frame.payload["id"] == 0 + && frame.payload["result"] == serde_json::json!({"outcome":{"outcome":"rejected"}}))); + retire(&handle).await; +} + +#[tokio::test] +#[ignore = "requires authenticated pinned Cursor CLI"] +async fn pinned_cursor_live_todo_request_gets_response() { + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let mut request = launch(root.path(), "CURSOR-LIVE-TODO", "none").await; + request.profile.command = pinned_cursor_binary(); + request.profile.args = vec!["acp".into()]; + request.profile.auth = Some(AcpAuth { + method_id: "cursor_login".into(), + }); + request + .profile + .extensions + .push("cursor@2026.09.08-6caf4ff".into()); + request + .context + .environment + .insert("HOME".into(), std::env::var("HOME").expect("HOME")); + request.limits.setup_timeout = Duration::from_secs(30); + request.limits.prompt_timeout = Duration::from_secs(150); + let handle = host.open(request).await.expect("open authenticated CLI"); + let owner_session = handle + .inspect() + .await + .expect("inspect") + .state + .session_id + .expect("session"); + assert!(handle.prompt( + "cursor-live-todo".into(), 1, + "Read-only ACP protocol test. Use built-in todo tracking to record one todo, 'Answer the color probe', mark it completed, then answer blue. Do not inspect or modify files, run commands, browse, or use MCP.".into(), + CancellationToken::new(), + ).await.expect("prompt").succeeded()); + let history = handle.source_history(); + let mut projection = RuntimeProjection::default().with_cursor(true, Some(&owner_session), None); + let todo_updates: Vec<_> = history + .events + .iter() + .filter_map(|event| projection.apply(event, "cursor-live-todo")) + .filter(|update| update.kind == "vendor_todos") + .collect(); + assert_eq!( + todo_updates.len(), + 1, + "only the accepted response projects todos" + ); + let frames: Vec<_> = history + .events + .into_iter() + .filter_map(|event| { + if let SessionEvent::Source { frame, .. } = event { + Some(frame) + } else { + None + } + }) + .collect(); + assert!(frames.iter().any(|frame| frame.direction == "incoming" + && frame.payload["method"] == "cursor/update_todos" + && frame.payload["id"] == 0 + && frame.payload["params"].get("sessionId").is_none())); + assert!(frames.iter().any(|frame| frame.direction == "outgoing" + && frame.payload["id"] == 0 + && frame.payload["result"]["outcome"]["outcome"] == "accepted" + && frame.payload["result"]["outcome"]["todos"].is_array())); + retire(&handle).await; +} async fn prompt(handle: &SessionHandle, run: &str, text: &str) -> TurnReport { handle .prompt(run.into(), 1, text.into(), CancellationToken::new()) diff --git a/tests/fixtures/acp_session_peer.py b/tests/fixtures/acp_session_peer.py index ee4973e4b..f1954ac65 100644 --- a/tests/fixtures/acp_session_peer.py +++ b/tests/fixtures/acp_session_peer.py @@ -16,6 +16,7 @@ serial = 100 abandoned_request = None pending_new = None +extension_prompt = None def send(payload): @@ -83,6 +84,8 @@ def callback(method, params, error=False): caps = {"loadSession": persistence == "load"} if persistence == "resume": caps["sessionCapabilities"] = {"resume": {}} + if mode.startswith('extension_echo'): + caps['_meta'] = {'opensymphony.dev/fixtureEcho': 1} send({"id": message["id"], "result": {"protocolVersion": 1, "agentCapabilities": caps}}) elif method == "session/new": if abandoned_request is not None: @@ -139,6 +142,39 @@ def callback(method, params, error=False): with open(".opensymphony/conversation.json") as file: assert json.load(file)["acp"]["status"] == "submitted" text = message["params"]["prompt"][0]["text"] + if text == 'extension-echo': + extension_prompt = message['id'] + pending = message['id'] + open('extension-ready', 'w').close() + continue + if text == 'cursor-extension': + send({'id': 99, 'method': 'cursor/create_plan', 'params': { + 'sessionId': 'wrong-session', 'toolCallId': 'malformed', 'plan': 'Nope', 'todos': []}}) + malformed = json.loads(sys.stdin.readline()) + assert malformed['id'] == 99 and malformed['result'] == {'outcome': {'outcome': 'cancelled'}}, malformed + send({'id': 0, 'method': 'cursor/create_plan', 'params': { + 'toolCallId': 'cursor-p1', 'name': 'Choose color', 'overview': 'Answer in chat', + 'plan': 'Answer blue in chat', 'todos': [], 'isProject': False, 'phases': []}}) + answer = json.loads(sys.stdin.readline()) + assert answer['id'] == 0, answer + assert answer['result'] == {'outcome': {'outcome': 'accepted'}}, answer + send({'id': 0, 'method': 'cursor/update_todos', 'params': { + 'toolCallId': 'cursor-t1', 'merge': False, + 'todos': [{'id': 't1', 'content': 'Verify', 'status': 'completed'}]}}) + todo = json.loads(sys.stdin.readline()) + assert todo['id'] == 0 and todo['result'] == {'outcome': {'outcome': 'accepted', + 'todos': [{'id': 't1', 'content': 'Verify', 'status': 'completed'}]}}, todo + send({'id': 101, 'method': 'cursor/update_todos', 'params': { + 'sessionId': 7, 'toolCallId': 'cursor-t2', 'merge': False, 'todos': []}}) + malformed_todo = json.loads(sys.stdin.readline()) + assert malformed_todo['id'] == 101 and malformed_todo['result'] == {'outcome': {'outcome': 'rejected'}}, malformed_todo + # A simulated no-ID frame gets no response; the pinned CLI did not emit one. + send({'method': 'cursor/update_todos', 'params': { + 'toolCallId': 'cursor-notification', 'merge': False, 'todos': []}}) + assert callback('cursor/ask_question', {'toolCallId': 'unqualified', 'questions': []}, error=True)['code'] == -32601 + assert callback('cursor/unknown', {'toolCallId': 'unknown'}, error=True)['code'] == -32601 + send({'id': message['id'], 'result': {'stopReason': 'end_turn'}}) + continue if text == 'form-no-route': result = callback('elicitation/create', {'mode': 'form', 'message': 'Choose region', 'requestedSchema': {'type': 'object', 'required': ['region'], 'properties': { @@ -260,6 +296,23 @@ def callback(method, params, error=False): continue update(text) send({"id": message["id"], "result": {"stopReason": "end_turn"}}) + elif method == '_opensymphony.test/echo': + assert extension_prompt is not None, message + assert message['params']['sessionId'] == session, message + assert message['params']['value'] == 'hello', message + assert message['params']['_meta'] == {'traceparent': 'trace-echo'}, message + if mode == 'extension_echo_timeout': + open('extension-request-count', 'a').write('1\n') + continue + if mode == 'extension_echo_secret': + secret = os.environ['ACCESS_TOKEN'] + send({'id': message['id'], 'result': {'value': secret, '_meta': {'traceparent': secret}}}) + send({'id': extension_prompt, 'result': {'stopReason': 'end_turn'}}) + extension_prompt = None + continue + send({'id': message['id'], 'result': {'value': 'hello', '_meta': {'traceparent': 'trace-echo'}}}) + send({'id': extension_prompt, 'result': {'stopReason': 'end_turn'}}) + extension_prompt = None elif method == "session/cancel": update("cancelling") send({"id": pending, "result": {"stopReason": "cancelled"}}) From 058c7f6983aa6ca5047308e04ad34bf33243d0bb Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Fri, 25 Sep 2026 02:06:37 -0500 Subject: [PATCH 22/29] Qualify live Cursor and Devin ACP runtime routing (#248) * feat(acp): qualify live Cursor and Devin routing Summary: - Bind bounded ACP updates that arrive before session/new responds, while retaining the response as the authoritative configuration snapshot. - Add tracked-issue live tests for two pinned CLIs, Cursor operator and cancellation paths, and live session/load restoration. - Publish the versioned interoperability matrix and executable evidence. Rationale: - Devin sends valid session updates before session/new responds; rejecting them prevents a real ACP worker from starting. - Capability claims require live vendor runs and explicit optional-method limits in addition to deterministic fake-peer conformance. Tests: - cargo test-system-duckdb (full suite), cargo clippy-system-duckdb - cargo fmt --check, git diff --check, npm run type-check - npx jest --config jest.config.js packages/gateway-schema --runInBand - Pinned Cursor and Devin ignored live qualification tests * fix(acp): count pending session update bytes incrementally Summary: - Track the pre-response update queue's frame and byte charges as updates arrive and reset charges when the authoritative session response drains it. - Add byte-limit/reset and pre-prompt overflow regression tests. Rationale: - Repeatedly serializing all pending updates on each notification made setup work quadratic at the permitted queue limit. Tests: - cargo test-system-duckdb (full suite, 1,474 library and 51 ACP tests) - cargo clippy-system-duckdb, cargo fmt --check, git diff --check --- crates/opensymphony-acp/src/lib.rs | 107 +++++++++- crates/opensymphony-cli/tests/run.rs | 290 ++++++++++++++++++++++++++ docs/acp-live-qualification.md | 79 +++++++ docs/architecture.md | 10 + docs/configuration.md | 12 ++ docs/harness-adapter-compatibility.md | 13 ++ docs/operations.md | 12 ++ docs/sources.md | 12 ++ docs/testing-and-operations.md | 12 ++ docs/workspace-and-lifecycle.md | 11 + tests/acp.rs | 83 ++++++++ tests/acp_session_host.rs | 79 +++++++ tests/fixtures/acp_peer.py | 15 +- 13 files changed, 727 insertions(+), 8 deletions(-) create mode 100644 docs/acp-live-qualification.md diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs index 3aa89755e..72459795c 100644 --- a/crates/opensymphony-acp/src/lib.rs +++ b/crates/opensymphony-acp/src/lib.rs @@ -586,6 +586,51 @@ impl Capture { type SharedCapture = Arc>; +#[derive(Default)] +struct PendingSessionUpdates { + frames: Vec<(String, Value)>, + bytes: usize, +} +impl PendingSessionUpdates { + fn push(&mut self, id: &str, update: &Value, limits: &ClientLimits) -> bool { + let bytes = id.len() + update.to_string().len(); + let Some(total) = self.bytes.checked_add(bytes) else { + return false; + }; + if self.frames.len() >= limits.queued_frames || total > limits.queued_bytes { + return false; + } + self.frames.push((id.to_owned(), update.clone())); + self.bytes = total; + true + } + + fn drain(&mut self) -> Vec<(String, Value)> { + self.bytes = 0; + std::mem::take(&mut self.frames) + } +} + +#[cfg(test)] +#[test] +fn pending_session_updates_bound_bytes_and_reset_after_drain() { + let update = json!({"sessionUpdate":"current_mode_update","currentModeId":"code"}); + let mut limits = ClientLimits { + queued_frames: 2, + queued_bytes: ("session".len() + update.to_string().len()) * 2 - 1, + ..ClientLimits::default() + }; + let mut pending = PendingSessionUpdates::default(); + assert!(pending.push("session", &update, &limits)); + assert!(!pending.push("session", &update, &limits)); + assert_eq!(pending.drain().len(), 1); + assert_eq!(pending.bytes, 0); + assert!(pending.push("session", &update, &limits)); + limits.queued_bytes += 1; + assert!(pending.push("session", &update, &limits)); + assert!(!pending.push("session", &update, &limits)); +} + /// Memory access belongs to the run-scoped worker overlay, never the daemon's /// ambient environment. This also protects terminal callbacks, which inherit /// the validated ACP child environment. @@ -1134,9 +1179,14 @@ async fn run_connection( } }); let active_session = Arc::new(Mutex::new(None::)); + // Some ACP peers announce initial configuration before replying to session/new. + // Keep those updates bounded until the response supplies the authoritative ID. + let pending_session_updates = Arc::new(Mutex::new(PendingSessionUpdates::default())); + let setup_updates = updates.clone(); let client = Client.builder().on_receive_dispatch( { let active_session = active_session.clone(); + let pending_session_updates = pending_session_updates.clone(); let callback_output = callback_output.clone(); let limits = limits.clone(); let resource_failure = resource_failure.clone(); @@ -1393,12 +1443,22 @@ async fn run_connection( fatal.cancel(); return Err(agent_client_protocol::Error::invalid_params()); }; - if active_session + let bound_session = active_session .lock() .unwrap_or_else(|e| e.into_inner()) - .as_deref() - != Some(id) - { + .clone(); + if bound_session.is_none() { + let mut pending = pending_session_updates + .lock() + .unwrap_or_else(|e| e.into_inner()); + if !pending.push(id, update, &limits) { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + return Err(agent_client_protocol::Error::internal_error()); + } + return Ok(()); + } + if bound_session.as_deref() != Some(id) { fatal.cancel(); return Err(agent_client_protocol::Error::invalid_params()); } @@ -1543,14 +1603,47 @@ async fn run_connection( let session_id = if let Some(session_id) = restored_id { session_id.into() } else { let (session_tx, session_rx) = oneshot::channel(); let active_session = active_session.clone(); + let pending_session_updates = pending_session_updates.clone(); let session_configuration = configuration.clone(); + let redactor = capture_state.clone(); + let updates = setup_updates.clone(); + let resource_failure = resource_failure.clone(); + let fatal = fatal.clone(); // Bind in ordered response dispatch before any adjacent session update. connection.send_request(NewSessionRequest::new(cwd.clone()).mcp_servers(context.services.mcp_servers.clone())) .on_receiving_result(async move |result| { if let Ok(session) = &result { - *active_session.lock().unwrap_or_else(|e| e.into_inner()) = - Some(session.session_id.0.to_string()); - session_configuration.lock().unwrap_or_else(|e| e.into_inner()).initial(&serde_json::to_value(session)?); + let session_id = session.session_id.0.to_string(); + let snapshot = serde_json::to_value(session)?; + session_configuration.lock().unwrap_or_else(|e| e.into_inner()).initial(&snapshot); + let early_updates = pending_session_updates.lock().unwrap_or_else(|e| e.into_inner()).drain(); + for (id, update) in early_updates { + if id != session_id { + fatal.cancel(); + return Err(agent_client_protocol::Error::invalid_params()); + } + // The response is authoritative where it supplies a field; + // an omitted field keeps its earlier announced value. + SessionConfiguration::default().update(&update)?; + let covered = match update.get("sessionUpdate").and_then(Value::as_str) { + Some("config_option_update") => snapshot.get("configOptions").is_some(), + Some("current_mode_update") => snapshot.pointer("/modes/currentModeId").is_some(), + _ => false, + }; + if !covered { + session_configuration.lock().unwrap_or_else(|e| e.into_inner()).update(&update)?; + } + if let Some(tx) = &updates { + let mut safe_update = update; + redactor.lock().unwrap_or_else(|e| e.into_inner()).redact(&mut safe_update, false); + tx.try_send(SessionUpdate { session_id: id, update: safe_update }).map_err(|_| { + resource_failure.store(true, Ordering::Release); + fatal.cancel(); + agent_client_protocol::Error::internal_error() + })?; + } + } + *active_session.lock().unwrap_or_else(|e| e.into_inner()) = Some(session_id); } let _ = session_tx.send(result); Ok(()) diff --git a/crates/opensymphony-cli/tests/run.rs b/crates/opensymphony-cli/tests/run.rs index 9d1f5be43..e2a804486 100644 --- a/crates/opensymphony-cli/tests/run.rs +++ b/crates/opensymphony-cli/tests/run.rs @@ -172,6 +172,296 @@ async fn run_dispatches_acp_with_exact_cwd_hooks_and_no_openhands() { terminate_child(&mut child).await; } +// Manual qualification: these tests require locally authenticated vendor CLIs. +// They drive the same tracked-issue `opensymphony run` route as the fake peer. +#[tokio::test] +#[ignore = "requires authenticated pinned Cursor CLI"] +async fn live_cursor_acp_tracked_issue_edits_workspace() { + live_acp_tracked_issue_edit( + "OPENSYMPHONY_CURSOR_AGENT_BIN", + "2026.09.08-6caf4ff", + "cursor", + " auth:\n method_id: cursor_login\n extensions: [cursor@2026.09.08-6caf4ff]\n", + ) + .await; +} + +#[tokio::test] +#[ignore = "requires authenticated pinned Devin CLI"] +async fn live_devin_acp_tracked_issue_edits_workspace() { + live_acp_tracked_issue_edit("OPENSYMPHONY_DEVIN_BIN", "3000.10.21", "devin", "").await; +} + +async fn live_acp_tracked_issue_edit( + binary_env: &str, + version: &str, + profile: &str, + profile_extra: &str, +) { + let binary = std::env::var(binary_env).expect("set vendor CLI path for manual test"); + let output = std::process::Command::new(&binary) + .arg("--version") + .output() + .expect("vendor CLI version"); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains(version)); + + let linear = MockLinearGraphqlServer::start_with_active_issue().await; + let project = TempDir::new().expect("project"); + let bind = reserve_socket_addr(); + write_project_files_with_workflow_extra( + project.path(), + linear.base_url(), + "http://127.0.0.1:9", + format!("control_plane:\n bind: {bind}\nmemory:\n auto_capture: false\n serve: false\n"), + &format!( + "routing:\n harness: acp\n harness_profile: {profile}\nacp:\n profiles:\n {profile}:\n command: {binary}\n args: [acp]\n permissions:\n mode: allow_once\n{profile_extra}polling:\n interval_ms: 50\n" + ), + ); + let workflow_path = project.path().join("WORKFLOW.md"); + let workflow = std::fs::read_to_string(&workflow_path).expect("workflow"); + std::fs::write( + workflow_path, + workflow.replace( + "Run the scheduler.", + "In the current issue workspace, create a file named acp-live-proof.txt containing exactly `ACP tracked issue proof` followed by a newline. Do not edit another file. After writing it, reply Done.", + ), + ) + .expect("workflow prompt"); + write_memory_config(project.path()); + + let mut child = spawn_run_child(project.path(), &[]); + let workspace = project.path().join("var/workspaces/COE-429"); + let deadline = Instant::now() + Duration::from_secs(300); + let result = loop { + if let Ok(raw) = std::fs::read_to_string(workspace.join(".opensymphony/run.json")) + && let Ok(run) = serde_json::from_str::(&raw) + && matches!( + run["status"].as_str(), + Some("succeeded" | "failed" | "cancelled") + ) + { + break run; + } + assert!( + child.try_wait().expect("run status").is_none(), + "run exited" + ); + assert!(Instant::now() < deadline, "vendor run timed out"); + sleep(Duration::from_millis(100)).await; + }; + terminate_child(&mut child).await; + assert_eq!(result["status"], "succeeded", "vendor tracked run failed"); + assert_eq!( + std::fs::read_to_string(workspace.join("acp-live-proof.txt")).expect("workspace edit"), + "ACP tracked issue proof\n" + ); + let route: Value = serde_json::from_str( + &std::fs::read_to_string(workspace.join(".opensymphony/harness-route.json")) + .expect("route"), + ) + .expect("route JSON"); + assert_eq!(route["harness_profile"], profile); + assert_eq!(route["harness_kind"], "acp"); + println!("{profile}: tracked issue succeeded; workspace edit and ACP route verified"); +} + +#[tokio::test] +#[ignore = "requires authenticated pinned Cursor CLI"] +async fn live_cursor_acp_tracked_issue_returns_operator_plan_decision() { + let binary = pinned_cursor_binary(); + let linear = MockLinearGraphqlServer::start_with_active_issue().await; + let project = TempDir::new().expect("project"); + let bind = reserve_socket_addr(); + write_project_files_with_workflow_extra( + project.path(), + linear.base_url(), + "http://127.0.0.1:9", + format!("control_plane:\n bind: {bind}\nmemory:\n auto_capture: false\n serve: false\n"), + &format!( + "routing:\n harness: acp\n harness_profile: cursor\nacp:\n profiles:\n cursor:\n command: {binary}\n args: [acp]\n auth:\n method_id: cursor_login\n extensions: [cursor@2026.09.08-6caf4ff]\n session:\n mode: plan\npolling:\n interval_ms: 50\n" + ), + ); + let workflow_path = project.path().join("WORKFLOW.md"); + let workflow = std::fs::read_to_string(&workflow_path).expect("workflow"); + std::fs::write( + workflow_path, + workflow.replace( + "Run the scheduler.", + "Read-only protocol test. Use CreatePlan to propose answering a color question entirely in chat. Wait for the operator decision, then reply blue. Do not inspect or edit files, run commands, browse, or use MCP.", + ), + ) + .expect("workflow prompt"); + write_memory_config(project.path()); + let mut child = spawn_run_child(project.path(), &[]); + let client = reqwest::Client::new(); + let deadline = Instant::now() + Duration::from_secs(240); + let interaction = loop { + if let Ok(response) = client + .get(format!("http://{bind}/api/v1/runs/COE-429/approvals")) + .send() + .await + && let Ok(body) = response.json::().await + && let Some(interaction) = body["approvals"].as_array().and_then(|approvals| { + approvals.iter().find_map(|approval| { + let interaction = &approval["operator_interaction"]; + (interaction["kind"] == "plan_approval").then(|| interaction.clone()) + }) + }) + { + break interaction; + } + assert!( + child.try_wait().expect("run status").is_none(), + "run exited" + ); + assert!(Instant::now() < deadline, "Cursor plan callback timed out"); + sleep(Duration::from_millis(100)).await; + }; + let mut payload = json!({ + "request_id": interaction["request_id"], + "run_id": interaction["run_id"], + "issue_id": interaction["issue_id"], + "session_id": interaction["session_id"], + "generation": interaction["generation"], + "rpc_id": interaction["rpc_id"], + "decision": "rejected" + }); + let dispatch = |payload: Value| { + json!({ + "schema_version": { "major": 1, "minor": 0, "patch": 0 }, + "correlation_id": uuid::Uuid::new_v4().to_string(), + "action_kind": "plan_decision", + "target_entity": { "entity_kind": "run", "entity_id": "COE-429" }, + "payload": payload + }) + }; + payload["session_id"] = json!("wrong-session"); + let rejected: Value = client + .post(format!("http://{bind}/api/v1/actions/dispatch")) + .json(&dispatch(payload.clone())) + .send() + .await + .expect("wrong-session reply") + .json() + .await + .expect("wrong-session receipt"); + assert_eq!(rejected["status"], "rejected"); + payload["session_id"] = interaction["session_id"].clone(); + let accepted: Value = client + .post(format!("http://{bind}/api/v1/actions/dispatch")) + .json(&dispatch(payload)) + .send() + .await + .expect("bound plan reply") + .json() + .await + .expect("plan receipt"); + assert_eq!(accepted["status"], "accepted"); + terminate_child(&mut child).await; + println!( + "cursor: tracked issue rejected cross-session reply and acknowledged bound plan decision" + ); +} + +#[tokio::test] +#[ignore = "requires authenticated pinned Cursor CLI"] +async fn live_cursor_acp_tracked_issue_cancel_acknowledgement() { + let binary = pinned_cursor_binary(); + let linear = MockLinearGraphqlServer::start_with_active_issue().await; + let project = TempDir::new().expect("project"); + let bind = reserve_socket_addr(); + write_project_files_with_workflow_extra( + project.path(), + linear.base_url(), + "http://127.0.0.1:9", + format!("control_plane:\n bind: {bind}\nmemory:\n auto_capture: false\n serve: false\n"), + &format!( + "routing:\n harness: acp\n harness_profile: cursor\nacp:\n profiles:\n cursor:\n command: {binary}\n args: [acp]\n auth:\n method_id: cursor_login\n extensions: [cursor@2026.09.08-6caf4ff]\n permissions:\n mode: allow_once\npolling:\n interval_ms: 5000\n" + ), + ); + let workflow_path = project.path().join("WORKFLOW.md"); + let workflow = std::fs::read_to_string(&workflow_path).expect("workflow"); + std::fs::write( + workflow_path, + workflow.replace( + "Run the scheduler.", + "Controlled cancellation test: run `sleep 90` in the current issue workspace, then reply Done. Do not edit files or run any other command.", + ), + ) + .expect("workflow prompt"); + write_memory_config(project.path()); + let mut child = spawn_run_child(project.path(), &[]); + let workspace = project.path().join("var/workspaces/COE-429"); + let deadline = Instant::now() + Duration::from_secs(180); + loop { + if let Ok(raw) = std::fs::read_to_string(workspace.join(".opensymphony/conversation.json")) + && let Ok(state) = serde_json::from_str::(&raw) + && state["acp"]["status"] == "submitted" + { + break; + } + assert!( + child.try_wait().expect("run status").is_none(), + "run exited" + ); + assert!(Instant::now() < deadline, "Cursor prompt not submitted"); + sleep(Duration::from_millis(100)).await; + } + sleep(Duration::from_secs(2)).await; + let receipt: Value = reqwest::Client::new() + .post(format!("http://{bind}/api/v1/actions/dispatch")) + .json(&json!({ + "schema_version": { "major": 1, "minor": 0, "patch": 0 }, + "correlation_id": uuid::Uuid::new_v4().to_string(), + "action_kind": "cancel", + "target_entity": { "entity_kind": "run", "entity_id": "COE-429" }, + "idempotency_key": "cancel-COE-429" + })) + .send() + .await + .expect("cancel action") + .json() + .await + .expect("cancel receipt"); + assert_eq!(receipt["status"], "accepted"); + loop { + if let Ok(response) = reqwest::get(format!("http://{bind}/api/v1/snapshot")).await + && let Ok(snapshot) = response.json::().await + && snapshot["snapshot"]["issues"][0]["recent_events"] + .as_array() + .is_some_and(|events| { + events.iter().any(|event| { + event["kind"] == "scheduler.interrupt_acknowledged" + && event["summary"] + .as_str() + .is_some_and(|summary| summary == "ACP prompt stopped: cancelled") + }) + }) + { + break; + } + assert!( + Instant::now() < deadline, + "Cursor cancellation not acknowledged" + ); + sleep(Duration::from_millis(25)).await; + } + terminate_child(&mut child).await; + println!("cursor: tracked issue cancellation acknowledged by matching prompt response"); +} + +fn pinned_cursor_binary() -> String { + let binary = std::env::var("OPENSYMPHONY_CURSOR_AGENT_BIN").expect("pinned Cursor CLI"); + let version = std::process::Command::new(&binary) + .arg("--version") + .output() + .expect("Cursor CLI version"); + assert!(version.status.success()); + assert!(String::from_utf8_lossy(&version.stdout).contains("2026.09.08-6caf4ff")); + binary +} + #[tokio::test] async fn run_auto_detects_config_and_workflow_from_project_directory() { let openhands = FakeOpenHandsServer::start() diff --git a/docs/acp-live-qualification.md b/docs/acp-live-qualification.md new file mode 100644 index 000000000..1b2debc4a --- /dev/null +++ b/docs/acp-live-qualification.md @@ -0,0 +1,79 @@ +# ACP v1 live qualification + +This report qualifies the local stdio ACP route on 2026-09-25 with two +independently implemented CLIs. The tests use disposable issue workspaces and a +local Linear GraphQL fixture, but launch the real `opensymphony run` command, +production routing and worker, gateway, and vendor ACP processes. The OpenHands +URL is deliberately unreachable in each edit scenario; a successful ACP run +therefore also checks that the route does not start or fall through to OpenHands. +No credentials, account identifiers, opaque session IDs, or unredacted source +frames are included in this report. + +| Profile | Pinned CLI and auth | ACP negotiation observed | Live result and boundary | +| --- | --- | --- | --- | +| Cursor | `agent` `2026.09.08-6caf4ff`; existing Cursor login, advertised `cursor_login` authentication | v1 stdio JSON-RPC; `loadSession: true`; no `resumeSession` advertisement; session modes and two config options | Tracked edit, gateway plan rejection, cancellation acknowledgement, `session/load` restoration, and registered `cursor/create_plan`/`cursor/update_todos` request-response paths passed. Only these observed extension methods are enabled for this pin. | +| Devin | `devin` `3000.10.21 (611c1cba)`; `devin auth status` reported logged in via Devin | v1 stdio JSON-RPC; `loadSession: true`; no `resumeSession` advertisement; session modes and three config options | Tracked edit and `session/load` restoration passed. The CLI sent `config_option_update`, `current_mode_update`, and `available_commands_update` for the new session before the `session/new` response. The response is authoritative for fields it supplies; earlier updates fill only omitted fields. No Devin extension is registered. | + +The two implementations were launched from separate vendor executables with +separate authentication and session implementations. Generic ACP v1 facilities +are negotiated per session; a configured profile does not imply that a model, +mode, MCP transport, or optional persistence method is available. Both live +CLIs advertised load and restored a known-finished, retired session with the +same bound session ID. The second process sent `session/load` before any new +`session/prompt`; a fresh second prompt then completed. Neither CLI advertised +`session/resume`, so no live resume-without-replay claim is made. The fake-peer +suite separately covers load replay, resume without replay, and the no-persistence +fresh-session path. Replayed source frames are tagged and excluded from live +usage accounting; a possibly submitted prompt is never replayed on recovery. + +## Reproduce + +Verify the CLI versions and authenticated status locally, then set +`OPENSYMPHONY_CURSOR_AGENT_BIN` and `OPENSYMPHONY_DEVIN_BIN` to the respective +executables. The manual tests are ignored by unauthenticated CI. On a macOS +installation with system DuckDB 1.5.3, run: + +```sh +agent --version +devin --version +devin auth status +cargo test-system-duckdb --test run live_cursor_acp_tracked_issue_edits_workspace -- --ignored --nocapture +cargo test-system-duckdb --test run live_devin_acp_tracked_issue_edits_workspace -- --ignored --nocapture +cargo test-system-duckdb --test run live_cursor_acp_tracked_issue_returns_operator_plan_decision -- --ignored --nocapture +cargo test-system-duckdb --test run live_cursor_acp_tracked_issue_cancel_acknowledgement -- --ignored --nocapture +cargo test-system-duckdb --test acp_session_host pinned_live_acp_session_load_restores_without_prompt_resend -- --ignored --nocapture +cargo test-system-duckdb --test acp_session_host pinned_cursor_live -- --ignored --nocapture +``` + +The first two `run` tests assert a succeeded tracked attempt, exact +`acp-live-proof.txt` workspace content, and the `acp` route/profile manifest. +The operator test rejects a wrong-session reply, then returns a plan rejection +through the gateway to the live Cursor callback. The cancellation test waits +for a submitted prompt, dispatches a gateway cancel action, then requires the +scheduler's `interrupt_acknowledged` event with `ACP prompt stopped: cancelled`. +The restoration test asserts a `session/load` source method, unchanged bound +session, no replayed prompt, and a successful subsequent prompt. The pinned +Cursor host tests assert the redacted source-frame method, ID-bearing request, +absence of a supplied `sessionId`, and the matching result shape for both +supported extension methods. See +[extension evidence](acp-extension-evidence.md) for their bounded schemas. + +## Conformance and failure matrix + +| Contract | Executable evidence | Qualification | +| --- | --- | --- | +| New-session update ordering | `tests/acp.rs` pre-response update, authoritative response, foreign-session, and overflow cases | Devin-compatible setup retains omitted fields and rejects a mismatched session before prompt submission. The pre-response queue tracks frame and byte budgets incrementally and resets its charge after drain. | +| Cross-session operator and callback safety | `tests/acp.rs`, `tests/acp_session_host.rs`, and live tracked Cursor plan test | Wrong-session replies and updates cannot reach another session. An accepted operator receipt requires callback delivery. | +| Cancellation and cleanup | Live tracked Cursor cancel; ACP host and worker cancellation tests | Acknowledgement follows the matching stopped prompt response. Unknown/uncertain submission fences cleanup and retry; owner retirement requires quiescence. | +| Continuation and restart | `tests/acp_session_host.rs` load/resume/none recovery and live Cursor/Devin load test; `crates/opensymphony-cli` worker recovery tests | Known-finished context restores only through advertised methods. No ambiguous prompt resend; absent persistence starts fresh with the full prompt. | +| Replay and usage | ACP host restoration and worker usage tests | Load history is tagged as replay and does not double-count usage; resume has no replay. | +| Model/config and credential variation | `tests/acp.rs` model/mode/options, environment-reference, authentication and redaction cases | The generic client validates configured choices and fails before prompt on unsupported selections or authentication failure. No cross-vendor live model equivalence is claimed. | +| Discovery and schema parity | Gateway schema round-trip, capabilities endpoint, adapter-boundary, and TypeScript checks | Public adapter support is distinct from profile preflight and run-negotiated capability. Preflight does not attest authentication. | + +The generic profile needs only `routing.harness: acp`, a selected +`routing.harness_profile`, and an `acp.profiles` stdio command/args entry. It +does not require a vendor-specific scheduler branch. Prefer `env_refs` for +credentials; never put secret values into the command, arguments, workflow, or +this evidence report. The process runs with the issue workspace as its cwd and +inherits trusted local host access, so choose profiles and permission policy +accordingly. Operator decisions require a live gateway response path. diff --git a/docs/architecture.md b/docs/architecture.md index f85713135..a3c7afcfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -566,6 +566,16 @@ silently drop the expiry signal. A receiver that stops draining fails the turn; worker completion clears its pending interactions. No OpenHands server or client participates in this launch path. +## ACP live interoperability + +The [ACP live qualification](acp-live-qualification.md) records production +`opensymphony run` paths for pinned Cursor and Devin stdio CLIs. Both use the +same scheduler-owned routing and issue workspace; vendor-specific behavior +stays inside the ACP client/registered extension boundary. Devin may announce +configuration before the `session/new` response, so the client buffers those +bounded announcements until it can bind the authoritative session ID. The +response wins for fields it supplies. + ## Current model diff --git a/docs/configuration.md b/docs/configuration.md index 271c3b5cd..824347c9a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1102,6 +1102,18 @@ redacted from source history and evidence. Model and mode categories are resolved from each refreshed option list so prerequisite selections can reveal dependent options. +## Qualified ACP profiles + +The [live qualification matrix](acp-live-qualification.md) gives pinned Cursor +and Devin versions and executable test commands. A new compliant stdio profile +uses the `routing.harness: acp` and `routing.harness_profile` keys shown +above, with `command`, `args`, and optional `env_refs`; no vendor-specific +scheduler code is needed. Profile preflight checks configuration and +executable availability. Authentication, session load, modes, options, and +extensions are reported from the negotiated live session. The pinned Cursor +extension registration is limited to the observed plan and todo request +methods. + ## Current model diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index cdada5592..98f991e93 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -101,6 +101,19 @@ Known gaps: tokens in OpenSymphony workspaces or browser payloads. - Hosted Codex worker pools and remote routing remain future work. +## ACP live profile matrix + +[ACP live qualification](acp-live-qualification.md) records the pinned Cursor +and Devin stdio profiles, negotiated ACP v1 methods, authentication, real +tracked-issue outcomes, and optional-feature limits. Both advertise +`loadSession` and omit `resumeSession`; profile preflight does not claim +either. Cursor alone has a qualified pinned extension registration for +observed `cursor/create_plan` and `cursor/update_todos` ID-bearing requests. +Devin requires no custom scheduler path; its before-response session updates +are bound by the common ACP client. The public capabilities response exposes +generic adapter support, configured profile readiness, and negotiated run +support as distinct states. + ## Rust-Native Harness A Rust-native or in-process harness fits the same contract by implementing diff --git a/docs/operations.md b/docs/operations.md index c459bec0e..c70719639 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1111,6 +1111,18 @@ inside the eight-operation limit until its reply or connection closure. Peer results are redacted with the session's known secrets before entering the public action receipt, including strings in `value` and `_meta`. +## ACP live qualification and recovery + +Run the ignored authenticated tests in [ACP live +qualification](acp-live-qualification.md) after checking both pinned CLI +versions and local login status. The report separates the local profile +preflight result from a completed tracked issue run. Cursor and Devin both +advertised `session/load` and restored a known-finished session; neither +advertised `session/resume` in this qualification. An unadvertised optional +method must not be assumed. A credential or option failure before prompt +submission is a setup failure; a possibly submitted prompt is fenced from +automatic resend and cleanup until reconciled. + ## Current model diff --git a/docs/sources.md b/docs/sources.md index 627cd0d99..11284bdec 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -261,6 +261,18 @@ through [Rust OpenOptionsExt](https://doc.rust-lang.org/std/os/windows/fs/trait. No-follow handles are checked before mutation, and ancestor handles exclude write/delete sharing until file I/O or terminal spawn finishes. +## ACP vendor pins and observed contract + +The [live qualification report](acp-live-qualification.md) pins Cursor CLI +`2026.09.08-6caf4ff` and Devin CLI `3000.10.21 (611c1cba)` as observed on +2026-09-25. Both negotiated ACP v1 over local stdio and advertised +`loadSession`; neither advertised `resumeSession`. Devin emitted configuration +updates before `session/new` replied. These are observed runtime contracts, +not assumptions about newer CLI releases. Re-run the ignored tests and revise +the pin before claiming another version. The supported Cursor extension shapes +and response observations are in [ACP extension +evidence](acp-extension-evidence.md). + ## Current model diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index ec4f99a00..b194e8cab 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -788,6 +788,18 @@ TypeScript capability fixture; native OpenHands and Codex regression suites rema required. Unset ambient `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET` for fake tracker suites so local OAuth configuration does not override fixture credentials. +## ACP live acceptance + +[ACP live qualification](acp-live-qualification.md) contains reproducible +commands and redacted outcomes for two independent vendor CLIs through tracked +`opensymphony run` paths. The ignored tests assert workspace edits, Cursor +operator decision and cancellation acknowledgement, plus live Cursor/Devin +`session/load` without prompt resend. `cargo test-system-duckdb --test acp` +covers the Devin-observed pre-response update ordering, authoritative +snapshot, and wrong-session rejection. Keep these manual tests separate from +unauthenticated CI and run the full system-DuckDB and Rust/TypeScript schema +checks for changes to the adapter. + ## Current model diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 5c0dcd6f8..8a1069571 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -1061,6 +1061,17 @@ retires accepted restoration work before creating a fresh session. path, so editor-like extension requests cannot replace cwd, environment, callbacks or MCP grants. +## ACP live restoration boundary + +The [pinned live qualification](acp-live-qualification.md) restored +known-finished Cursor and Devin sessions through `session/load` after retiring +the first process. The second process kept the same bound session ID and did +not resend the first prompt. For a peer without advertised persistence, +restoration starts a fresh session and uses the full prompt when the old +workflow context has not been preserved. A submitted prompt with unknown +outcome remains fenced; a stopped owner alone is insufficient evidence for +automatic prompt replay or workspace removal. + ## Current model diff --git a/tests/acp.rs b/tests/acp.rs index 797d45a87..6c5a64fea 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -226,6 +226,89 @@ async fn acp_binds_new_session_before_adjacent_update_dispatch() { } } +#[tokio::test(flavor = "current_thread")] +async fn acp_binds_updates_announced_before_new_session_response() { + for (mode, expected_mode) in [ + ("pre_response_session_update", "code"), + ("pre_response_authoritative_snapshot", "plan"), + ] { + let root = tempfile::tempdir().expect("temp"); + let (tx, mut rx) = tokio::sync::mpsc::channel(4); + let run = run_turn( + &profile(mode), + context(root.path()), + "hello".into(), + CancellationToken::new(), + Some(tx), + limits(), + ) + .await + .expect("launch"); + let report = run.outcome.expect("bound early update"); + assert!(report.succeeded()); + assert_eq!( + report.configuration.current_mode.as_deref(), + Some(expected_mode) + ); + let update = rx.recv().await.expect("early update"); + assert_eq!(update.session_id, "opaque/session:zero"); + assert_eq!(update.update["currentModeId"], "code"); + } +} + +#[tokio::test] +async fn acp_rejects_early_update_for_another_session() { + let root = tempfile::tempdir().expect("temp"); + let run = run_turn( + &profile("pre_response_wrong_session"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + limits(), + ) + .await + .expect("launch"); + assert_eq!( + run.outcome.expect_err("foreign session update must fail"), + ClientError::Protocol { submitted: false } + ); + assert!(run.process_reaped); + assert!( + !run.evidence + .iter() + .any(|frame| frame.direction == "outgoing" + && frame.payload["method"] == "session/prompt") + ); +} + +#[tokio::test] +async fn acp_bounds_updates_before_new_session_response() { + let root = tempfile::tempdir().expect("temp"); + let run = run_turn( + &profile("pre_response_updates_overflow"), + context(root.path()), + "hello".into(), + CancellationToken::new(), + None, + ClientLimits { + queued_frames: 1, + ..limits() + }, + ) + .await + .expect("launch"); + assert_eq!( + run.outcome + .expect_err("early update queue must remain bounded"), + ClientError::ResourceLimit { submitted: false } + ); + assert!(run.process_reaped); + assert!(!run.evidence.iter().any(|frame| { + frame.direction == "outgoing" && frame.payload["method"] == "session/prompt" + })); +} + #[tokio::test(flavor = "current_thread")] async fn acp_bounds_callback_output_before_sdk_enqueue_when_stdin_is_blocked() { for mode in [ diff --git a/tests/acp_session_host.rs b/tests/acp_session_host.rs index b18d1642d..f8dae51ab 100644 --- a/tests/acp_session_host.rs +++ b/tests/acp_session_host.rs @@ -528,6 +528,85 @@ fn pinned_cursor_binary() -> String { binary } +#[tokio::test] +#[ignore = "requires authenticated pinned vendor CLI"] +async fn pinned_live_acp_session_load_restores_without_prompt_resend() { + for (env_name, issue) in [ + ("OPENSYMPHONY_CURSOR_AGENT_BIN", "CURSOR-LIVE-RESTORE"), + ("OPENSYMPHONY_DEVIN_BIN", "DEVIN-LIVE-RESTORE"), + ] { + let binary = std::env::var(env_name).expect("pinned vendor CLI path"); + let root = tempfile::tempdir().expect("temp"); + let host = SessionHost::new(RetentionPolicy::default()).expect("host"); + let configure = |mut request: SessionLaunch| { + request.profile.command = binary.clone(); + request.profile.args = vec!["acp".into()]; + if env_name == "OPENSYMPHONY_CURSOR_AGENT_BIN" { + request.profile.auth = Some(AcpAuth { + method_id: "cursor_login".into(), + }); + request + .profile + .extensions + .push("cursor@2026.09.08-6caf4ff".into()); + } + request + .context + .environment + .insert("HOME".into(), std::env::var("HOME").expect("HOME")); + request.limits.setup_timeout = Duration::from_secs(30); + request.limits.prompt_timeout = Duration::from_secs(120); + request + }; + let first = host + .open(configure(launch(root.path(), issue, "none").await)) + .await + .expect("open vendor CLI"); + assert!( + prompt( + &first, + "first", + "Answer with one word: blue. Do not use tools or inspect files." + ) + .await + .succeeded() + ); + let original = first + .inspect() + .await + .expect("inspect") + .state + .session_id + .expect("session"); + retire(&first).await; + let restored = host + .open(configure(launch(root.path(), issue, "none").await)) + .await + .expect("restore vendor CLI"); + let state = restored.inspect().await.expect("restored state").state; + assert_eq!(state.recovery, AcpRecovery::RestoredLoad); + assert_eq!(state.session_id.as_deref(), Some(original.as_str())); + let history = restored.source_history(); + assert!(history.events.iter().any(|event| { + matches!(event, SessionEvent::Source { frame, .. } if frame.direction == "outgoing" && frame.payload["method"] == "session/load") + })); + assert!(!history.events.iter().any(|event| { + matches!(event, SessionEvent::Source { frame, .. } if frame.direction == "outgoing" && frame.payload["method"] == "session/prompt") + })); + assert!( + prompt( + &restored, + "second", + "Answer with one word: green. Do not use tools or inspect files." + ) + .await + .succeeded() + ); + retire(&restored).await; + println!("{issue}: session/load restored the bound session without prompt resend"); + } +} + #[tokio::test] #[ignore = "requires authenticated pinned Cursor CLI"] async fn pinned_cursor_live_plan_uses_production_operator_route() { diff --git a/tests/fixtures/acp_peer.py b/tests/fixtures/acp_peer.py index d675cf8b3..e23700f8d 100644 --- a/tests/fixtures/acp_peer.py +++ b/tests/fixtures/acp_peer.py @@ -64,6 +64,19 @@ def update(text): ] sys.stdout.write("".join(json.dumps(frame) + "\n" for frame in frames)) sys.stdout.flush() + elif mode in ("pre_response_session_update", "pre_response_authoritative_snapshot", "pre_response_wrong_session", "pre_response_updates_overflow"): + announced = "other-session" if mode == "pre_response_wrong_session" else session + send({"method": "session/update", "params": { + "sessionId": announced, + "update": {"sessionUpdate": "current_mode_update", "currentModeId": "code"}}}) + if mode == "pre_response_updates_overflow": + send({"method": "session/update", "params": { + "sessionId": session, + "update": {"sessionUpdate": "current_mode_update", "currentModeId": "plan"}}}) + result = {"sessionId": session} + if mode == "pre_response_authoritative_snapshot": + result["modes"] = {"currentModeId": "plan", "availableModes": [{"id": "plan", "name": "Plan"}]} + respond(message, result) else: respond(message, {"sessionId": session}) elif method == "session/prompt": @@ -75,7 +88,7 @@ def update(text): "requestedSchema": {"type": "object", "required": ["region"], "properties": { "region": {"type": "string", "enum": ["west", "east"]}}}}}) continue - if mode == "adjacent_session_update": + if mode in ("adjacent_session_update", "pre_response_session_update", "pre_response_authoritative_snapshot"): respond(message, {"stopReason": "end_turn"}) continue if mode.startswith("blocked_callbacks_"): From 0beecdf7f2d16e9bdb55e76a0746c0502cc0dec0 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Fri, 25 Sep 2026 02:09:55 -0500 Subject: [PATCH 23/29] docs(memory): sync completed ACP runtime issues Summary: - Capture verified COE-613 and COE-615 PR and merge evidence. - Sync the full 180-issue memory corpus into managed topic docs. Rationale: - Keep post-merge project memory and public source refs current. - Preserve existing authored docs outside managed memory sections. Tests: - opensymphony memory lint --public-docs - git diff --check --- .opensymphony/memory/memory.yaml | 44 ++++++++++++++++++++++++++++++++ docs/accessibility.md | 4 +++ docs/acp.md | 5 ++++ docs/architecture.md | 4 +++ docs/build.md | 4 +++ docs/cli.md | 4 +++ docs/code-intelligence.md | 4 +++ docs/code-review.md | 4 +++ docs/codex-runtime.md | 4 +++ docs/codex.md | 4 +++ docs/configuration.md | 4 +++ docs/control.md | 4 +++ docs/data-contracts.md | 4 +++ docs/debugging.md | 4 +++ docs/desktop.md | 4 +++ docs/developer-experience.md | 4 +++ docs/docs.md | 4 +++ docs/documentation.md | 4 +++ docs/duckdb.md | 4 +++ docs/evidence.md | 4 +++ docs/experiments.md | 4 +++ docs/frontend.md | 4 +++ docs/gateway.md | 4 +++ docs/graph-view.md | 4 +++ docs/harness-runtime.md | 4 +++ docs/harness.md | 4 +++ docs/installer.md | 4 +++ docs/linear.md | 4 +++ docs/mcp.md | 4 +++ docs/memory.md | 4 +++ docs/migration.md | 4 +++ docs/model-configuration.md | 4 +++ docs/okf.md | 4 +++ docs/openhands-runtime.md | 4 +++ docs/operations.md | 4 +++ docs/orchestrator.md | 4 +++ docs/outcomes.md | 4 +++ docs/planning.md | 4 +++ docs/process-protocol.md | 4 +++ docs/provenance.md | 4 +++ docs/provider.md | 4 +++ docs/quality.md | 4 +++ docs/query-packs.md | 4 +++ docs/recovery.md | 4 +++ docs/release.md | 4 +++ docs/review.md | 4 +++ docs/run-detail.md | 4 +++ docs/runner.md | 4 +++ docs/runtime.md | 4 +++ docs/rust.md | 4 +++ docs/scheduler.md | 4 +++ docs/security.md | 4 +++ docs/sources.md | 4 +++ docs/target-repo-bootstrap.md | 4 +++ docs/task-packets.md | 4 +++ docs/template.md | 4 +++ docs/testing-and-operations.md | 4 +++ docs/testing.md | 4 +++ docs/tests.md | 4 +++ docs/threejs.md | 4 +++ docs/tui.md | 4 +++ docs/ui.md | 4 +++ docs/verifiers.md | 4 +++ docs/web.md | 4 +++ docs/workflow.md | 4 +++ docs/workspace-and-lifecycle.md | 4 +++ docs/workspace-lifecycle.md | 4 +++ docs/workspace.md | 4 +++ 68 files changed, 313 insertions(+) diff --git a/.opensymphony/memory/memory.yaml b/.opensymphony/memory/memory.yaml index 941745b1e..af2cccab1 100644 --- a/.opensymphony/memory/memory.yaml +++ b/.opensymphony/memory/memory.yaml @@ -38,6 +38,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -107,9 +108,11 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:orchestrator - area:runtime + - area:testing - area:ui - area:workspace linear_milestones: @@ -370,6 +373,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -447,6 +451,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -639,6 +644,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -719,6 +725,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -892,6 +899,7 @@ areas: - area:cli - area:configuration - area:data-contracts + - area:docs - area:evidence - area:experiments - area:gateway @@ -939,6 +947,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -1345,6 +1354,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -1518,6 +1528,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:evidence - area:experiments - area:gateway @@ -1558,6 +1569,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:experiments - area:gateway - area:harness @@ -1596,6 +1608,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -1765,6 +1778,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -1834,6 +1848,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -2431,6 +2446,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -2730,6 +2746,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -2992,6 +3009,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -3030,6 +3048,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -3117,6 +3136,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -3148,6 +3168,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -3179,12 +3200,14 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:orchestrator - area:provider - area:recovery - area:review - area:runtime + - area:testing - area:ui - area:workspace - area:workspace-lifecycle @@ -3204,6 +3227,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:evidence - area:experiments - area:gateway @@ -3252,6 +3276,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -3321,10 +3346,12 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:orchestrator - area:recovery - area:runtime + - area:testing - area:ui - area:workspace - area:workspace-lifecycle @@ -3351,6 +3378,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -3440,11 +3468,13 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:orchestrator - area:recovery - area:review - area:runtime + - area:testing - area:ui - area:workspace - area:workspace-lifecycle @@ -3472,6 +3502,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -3541,6 +3572,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -3670,6 +3702,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -3740,6 +3773,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -3905,6 +3939,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -3975,6 +4010,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -4012,6 +4048,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -4213,6 +4250,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -4290,6 +4328,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -4556,6 +4595,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -4593,6 +4633,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -4684,6 +4725,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence @@ -4754,6 +4796,7 @@ areas: - area:acp - area:cli - area:configuration + - area:docs - area:gateway - area:harness - area:memory @@ -4806,6 +4849,7 @@ areas: - area:data-contracts - area:debugging - area:desktop + - area:docs - area:documentation - area:duckdb - area:evidence diff --git a/docs/accessibility.md b/docs/accessibility.md index a1ecbfb96..4d20fa859 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -94,6 +94,8 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -158,5 +160,7 @@ last_memory_sync: 2026-07-02T03:46:15.369986+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/acp.md b/docs/acp.md index 23582dd18..cb42eaeb8 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -16,6 +16,7 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-610 contributed: PR #242: feat(acp): implement host callbacks and session configuration (merge `cd683ea`) - COE-611 contributed: PR #245: COE-611: Route production workers through ACP profiles (merge `80e5ac3`) - COE-612 contributed: PR #246: Route ACP operator requests through scheduler and clients (merge `a6cd118`) +- COE-613 contributed: PR #247: COE-613: Add registered ACP extensions and harness operations (merge `90154b5`) ## Important invariants @@ -37,6 +38,8 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -45,5 +48,7 @@ last_memory_sync: 2026-09-22T11:51:20.924956+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/architecture.md b/docs/architecture.md index a3c7afcfa..1c3dc1a3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -739,6 +739,8 @@ response wins for fields it supplies. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -879,5 +881,7 @@ response wins for fields it supplies. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/build.md b/docs/build.md index 1072c7c5f..43f823e93 100644 --- a/docs/build.md +++ b/docs/build.md @@ -135,6 +135,8 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -240,5 +242,7 @@ last_memory_sync: 2026-06-14T22:09:16.647206+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/cli.md b/docs/cli.md index 88d3af0d2..63d51ec01 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -132,6 +132,8 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -234,5 +236,7 @@ last_memory_sync: 2026-05-16T07:06:20.437326+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md index dac7c3f2e..5ecbe355b 100644 --- a/docs/code-intelligence.md +++ b/docs/code-intelligence.md @@ -288,6 +288,8 @@ references, and never returns source snippets. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -352,5 +354,7 @@ references, and never returns source snippets. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/code-review.md b/docs/code-review.md index 95ffdea91..e0ca15d88 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -95,6 +95,8 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -160,5 +162,7 @@ last_memory_sync: 2026-07-03T18:18:35.870453+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/codex-runtime.md b/docs/codex-runtime.md index c44120e33..4394cfca6 100644 --- a/docs/codex-runtime.md +++ b/docs/codex-runtime.md @@ -95,6 +95,8 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -160,5 +162,7 @@ last_memory_sync: 2026-07-02T03:36:11.763156+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/codex.md b/docs/codex.md index c699e0b6b..b1721f547 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -134,6 +134,8 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -238,5 +240,7 @@ last_memory_sync: 2026-06-21T19:06:47.497281+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/configuration.md b/docs/configuration.md index 824347c9a..bde506435 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1299,6 +1299,8 @@ methods. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -1461,5 +1463,7 @@ methods. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/control.md b/docs/control.md index e092bebc5..406d07614 100644 --- a/docs/control.md +++ b/docs/control.md @@ -134,6 +134,8 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -238,5 +240,7 @@ last_memory_sync: 2026-06-27T15:38:16.487547+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 6e880772c..208162066 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -48,6 +48,8 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -66,5 +68,7 @@ last_memory_sync: 2026-08-29T03:08:56.598233+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/debugging.md b/docs/debugging.md index 87acef6fc..80b5ca383 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -91,6 +91,8 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -152,5 +154,7 @@ last_memory_sync: 2026-07-11T17:33:05.755457+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/desktop.md b/docs/desktop.md index 110acb05d..b366b9be2 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -135,6 +135,8 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -242,6 +244,8 @@ last_memory_sync: 2026-06-21T19:06:47.514408+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 - PR-196 - PR-197 diff --git a/docs/developer-experience.md b/docs/developer-experience.md index b3856aae4..5d0b9be00 100644 --- a/docs/developer-experience.md +++ b/docs/developer-experience.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-14T22:09:16.647708+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/docs.md b/docs/docs.md index 58644c44b..f4961ecc1 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -135,6 +135,8 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification - PR-197: Graph visualization command center and shared fixtures ## Source refs @@ -241,6 +243,8 @@ last_memory_sync: 2026-06-24T03:31:40.851743+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 - PR-197 diff --git a/docs/documentation.md b/docs/documentation.md index d7e5a0022..d522f3fec 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -96,6 +96,8 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -162,5 +164,7 @@ last_memory_sync: 2026-07-02T03:46:15.373329+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/duckdb.md b/docs/duckdb.md index 835245395..a0ea4a0d9 100644 --- a/docs/duckdb.md +++ b/docs/duckdb.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-28T06:08:14.398767+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/evidence.md b/docs/evidence.md index f05446d4c..373cd1b28 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -48,6 +48,8 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -66,5 +68,7 @@ last_memory_sync: 2026-08-29T03:08:56.598900+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/experiments.md b/docs/experiments.md index 5079956c3..6a12f028b 100644 --- a/docs/experiments.md +++ b/docs/experiments.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.063939+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/frontend.md b/docs/frontend.md index 870daa565..69b2b7b60 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -95,6 +95,8 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -162,6 +164,8 @@ last_memory_sync: 2026-07-02T03:46:15.373401+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 - PR-196 - PR-197 diff --git a/docs/gateway.md b/docs/gateway.md index 92e4ea7e2..50127b267 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -156,6 +156,8 @@ baseline, not an issue-workspace overlay. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification - PR-196: Desktop task and diff latency plus live-refresh UX fixes ## Source refs @@ -262,6 +264,8 @@ baseline, not an issue-workspace overlay. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 - PR-196 diff --git a/docs/graph-view.md b/docs/graph-view.md index ada658d2f..e5fc932eb 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -94,6 +94,8 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -160,6 +162,8 @@ last_memory_sync: 2026-07-02T03:46:15.373470+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 - PR-196 - PR-197 diff --git a/docs/harness-runtime.md b/docs/harness-runtime.md index 94f3a7ee7..220b30faa 100644 --- a/docs/harness-runtime.md +++ b/docs/harness-runtime.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-28T00:03:32.411843+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/harness.md b/docs/harness.md index 85e39c4c5..129cba87a 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -42,6 +42,8 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -54,5 +56,7 @@ last_memory_sync: 2026-09-12T12:25:39.170247+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/installer.md b/docs/installer.md index e8eb09585..d841ed8f6 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-28T00:04:20.497520+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/linear.md b/docs/linear.md index a614e5fae..aceab4181 100644 --- a/docs/linear.md +++ b/docs/linear.md @@ -135,6 +135,8 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -240,5 +242,7 @@ last_memory_sync: 2026-06-27T15:38:16.493904+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/mcp.md b/docs/mcp.md index 873a4d4ee..6b38b8f85 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-24T03:31:40.852180+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/memory.md b/docs/memory.md index 66105a8c4..e8fd8f524 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -848,6 +848,8 @@ are covered by the same archive operation. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -1018,5 +1020,7 @@ are covered by the same archive operation. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/migration.md b/docs/migration.md index 0ad60d6fa..d1771d06a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -91,6 +91,8 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -152,5 +154,7 @@ last_memory_sync: 2026-07-30T23:13:45.229665+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/model-configuration.md b/docs/model-configuration.md index f56b28cdb..c13ff1b63 100644 --- a/docs/model-configuration.md +++ b/docs/model-configuration.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-21T19:11:22.261830+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/okf.md b/docs/okf.md index 9f125a8dc..8296835cc 100644 --- a/docs/okf.md +++ b/docs/okf.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-23T15:32:29.522793+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/openhands-runtime.md b/docs/openhands-runtime.md index d2cf3cd38..2a4f207fd 100644 --- a/docs/openhands-runtime.md +++ b/docs/openhands-runtime.md @@ -95,6 +95,8 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -160,5 +162,7 @@ last_memory_sync: 2026-07-02T03:36:15.105926+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/operations.md b/docs/operations.md index c70719639..833f0fd48 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1319,6 +1319,8 @@ automatic resend and cleanup until reconciled. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -1492,5 +1494,7 @@ automatic resend and cleanup until reconciled. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index 77dfabefa..c7c3781bb 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -134,6 +134,8 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -238,5 +240,7 @@ last_memory_sync: 2026-06-28T00:03:32.416917+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/outcomes.md b/docs/outcomes.md index 334cc362c..44ebfdf09 100644 --- a/docs/outcomes.md +++ b/docs/outcomes.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.081713+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/planning.md b/docs/planning.md index a150bf0d9..02d614d3e 100644 --- a/docs/planning.md +++ b/docs/planning.md @@ -93,6 +93,8 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -156,5 +158,7 @@ last_memory_sync: 2026-07-31T10:05:00.869005+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/process-protocol.md b/docs/process-protocol.md index f1075bf50..29fdb7fb9 100644 --- a/docs/process-protocol.md +++ b/docs/process-protocol.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.082295+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/provenance.md b/docs/provenance.md index bffa46b96..bf2356561 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.082316+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/provider.md b/docs/provider.md index e94475fc9..9b5e12945 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -40,6 +40,8 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -50,5 +52,7 @@ last_memory_sync: 2026-09-13T06:27:30.577415+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/quality.md b/docs/quality.md index 18d8cf848..d24b920b3 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -48,6 +48,8 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -66,5 +68,7 @@ last_memory_sync: 2026-08-29T03:08:56.607991+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/query-packs.md b/docs/query-packs.md index c9c1237f6..64cfa0b78 100644 --- a/docs/query-packs.md +++ b/docs/query-packs.md @@ -94,6 +94,8 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -158,5 +160,7 @@ last_memory_sync: 2026-07-02T03:46:15.376296+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/recovery.md b/docs/recovery.md index fcf3ac7b5..6a844b51c 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -39,6 +39,8 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -48,5 +50,7 @@ last_memory_sync: 2026-09-13T11:59:04.642113+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/release.md b/docs/release.md index 965af4ed5..126867ce1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -92,6 +92,8 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -154,6 +156,8 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 ## 2.11 Rust toolchain boundary diff --git a/docs/review.md b/docs/review.md index b5b7f1066..397f63d03 100644 --- a/docs/review.md +++ b/docs/review.md @@ -40,6 +40,8 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -50,5 +52,7 @@ last_memory_sync: 2026-09-13T06:27:30.581384+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/run-detail.md b/docs/run-detail.md index 0b6d03207..42469242b 100644 --- a/docs/run-detail.md +++ b/docs/run-detail.md @@ -92,6 +92,8 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -154,5 +156,7 @@ last_memory_sync: 2026-07-12T04:38:41.689952+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/runner.md b/docs/runner.md index f08821dd6..058f3c643 100644 --- a/docs/runner.md +++ b/docs/runner.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.083609+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/runtime.md b/docs/runtime.md index e861d1e29..0797bcf22 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -134,6 +134,8 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -238,5 +240,7 @@ last_memory_sync: 2026-06-21T19:11:22.264899+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/rust.md b/docs/rust.md index 2b2fed59e..3b1e08d88 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -96,6 +96,8 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -162,5 +164,7 @@ last_memory_sync: 2026-07-02T03:46:15.376601+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/scheduler.md b/docs/scheduler.md index 65f06a5ab..b4aa74d47 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.084415+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/security.md b/docs/security.md index 214f05376..fce30fb7f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -135,6 +135,8 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -240,5 +242,7 @@ last_memory_sync: 2026-06-23T15:32:29.524342+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/sources.md b/docs/sources.md index 11284bdec..68a675858 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -382,6 +382,8 @@ evidence](acp-extension-evidence.md). - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -468,5 +470,7 @@ evidence](acp-extension-evidence.md). - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/target-repo-bootstrap.md b/docs/target-repo-bootstrap.md index d59f8899d..39116c435 100644 --- a/docs/target-repo-bootstrap.md +++ b/docs/target-repo-bootstrap.md @@ -91,6 +91,8 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -152,5 +154,7 @@ last_memory_sync: 2026-07-03T14:47:49.886918+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/task-packets.md b/docs/task-packets.md index 47f191e19..19f9ad6f7 100644 --- a/docs/task-packets.md +++ b/docs/task-packets.md @@ -44,6 +44,8 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -58,5 +60,7 @@ last_memory_sync: 2026-08-31T00:07:01.088374+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/template.md b/docs/template.md index 80773c957..95da6fd67 100644 --- a/docs/template.md +++ b/docs/template.md @@ -91,6 +91,8 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -152,5 +154,7 @@ last_memory_sync: 2026-07-04T01:04:06.159695+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index b194e8cab..bcf59052b 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -979,6 +979,8 @@ checks for changes to the adapter. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -1135,5 +1137,7 @@ checks for changes to the adapter. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/testing.md b/docs/testing.md index 436b12a9e..a54bcbbdb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -133,6 +133,8 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -238,6 +240,8 @@ last_memory_sync: 2026-06-28T06:08:14.403748+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 - PR-196 - PR-197 diff --git a/docs/tests.md b/docs/tests.md index c74d3d666..5ca668670 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -96,6 +96,8 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -162,5 +164,7 @@ last_memory_sync: 2026-07-02T03:46:15.378630+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/threejs.md b/docs/threejs.md index c0f7d2c8c..a4c9a304e 100644 --- a/docs/threejs.md +++ b/docs/threejs.md @@ -94,6 +94,8 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -158,5 +160,7 @@ last_memory_sync: 2026-07-02T03:46:15.378652+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/tui.md b/docs/tui.md index 0445749a5..035f0957d 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -230,5 +232,7 @@ last_memory_sync: 2026-06-21T19:06:47.556695+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/ui.md b/docs/ui.md index fe0e264a4..9dd288a2a 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -130,6 +130,8 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification - PR-196: Desktop task and diff latency plus live-refresh UX fixes - PR-197: Graph visualization command center and shared fixtures @@ -232,6 +234,8 @@ last_memory_sync: 2026-06-21T19:11:22.267420+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 - PR-196 - PR-197 diff --git a/docs/verifiers.md b/docs/verifiers.md index 442b134c6..1883e299f 100644 --- a/docs/verifiers.md +++ b/docs/verifiers.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.092515+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/web.md b/docs/web.md index 9dec0517a..9a993b8e5 100644 --- a/docs/web.md +++ b/docs/web.md @@ -91,6 +91,8 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -152,5 +154,7 @@ last_memory_sync: 2026-07-04T19:32:00.489442+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/workflow.md b/docs/workflow.md index 140309728..1af8b4a7e 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -96,6 +96,8 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -162,5 +164,7 @@ last_memory_sync: 2026-07-02T03:36:18.853412+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 8a1069571..65f7b61a5 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -1119,6 +1119,8 @@ automatic prompt replay or workspace removal. - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -1143,5 +1145,7 @@ automatic prompt replay or workspace removal. - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/workspace-lifecycle.md b/docs/workspace-lifecycle.md index bb8936394..f5a9b952b 100644 --- a/docs/workspace-lifecycle.md +++ b/docs/workspace-lifecycle.md @@ -91,6 +91,8 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -152,5 +154,7 @@ last_memory_sync: 2026-07-10T23:53:09.834546+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 diff --git a/docs/workspace.md b/docs/workspace.md index b18245a81..1d85ddb35 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -43,6 +43,8 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-610: ACP Client Callbacks And Session Configuration - COE-611: ACP Execution Routing And Worker Integration - COE-612: ACP Operator Requests And Response Routing +- COE-613: ACP Extensions And Harness Operations +- COE-615: ACP Runtime Conformance And Live Qualification ## Source refs @@ -56,5 +58,7 @@ last_memory_sync: 2026-08-31T00:07:01.093837+00:00 - COE-610 - COE-611 - COE-612 +- COE-613 +- COE-615 From 2102238dcc72ab72c5f7d88eb29d651a17febc1d Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 26 Sep 2026 12:46:51 -0500 Subject: [PATCH 24/29] test: gate multi-repository lifecycle rollout (#244) * test: add hermetic multi-repository rollout gate * fix(rollout): let orchestrator publish parent repair Keep the live parent harness focused on editing the verified checkout, then wait for the orchestrator to publish and merge its durable repair PR. Record completed controller evidence before marking the parent Done. Validated with bash -n and git diff --check. * fix(rollout): use automated checks without reviewer token * test(rollout): validate central config and three-repo lifecycle * fix(rollout): harden disposable lifecycle controller * fix(rollout): bound provisioning and deliver rework evidence * test: bound disposable lifecycle rollout and test stack * fix: make disposable lifecycle evidence teardown-safe * fix: isolate live fixture publishing and bounded shutdown * Fix disposable lifecycle worker completion and parent repair guidance * fix: place live memory catalog under state root * fix: canonicalize hermetic temporary directory * fix: accept an already-created disposable pull request * fix: use repository REST APIs in disposable PR flow * fix: reject empty disposable PR lookup * fix: gate disposable publishing on controller outcome * fix: refresh completed parent fan-in before hourly scan * fix: transition disposable children after durable completion * test: allow durable child handoff in live rollout * test: gate live publication on stopped run receipt * fix: accept issue-bound semantic child merge branches * test: use verifiable merge commits in disposable rollout * test: validate checks directly for private live fixtures * test: leave published child checkouts clean for parent fan-in * Clarify exact parent verification command in live fixture * Refresh parent retries on terminal cadence * Pin parent fixture verification command invocation * Reopen claimed parent workspace for integration retries * test: route live memory capture through stable fixture checkouts * fix: release parent checkout pins during terminal cleanup * test: exclude durable cleanup tombstones from live workspace check * fix: release checkout staging intent after terminal cleanup * test: force first disposable alpha PR through CI repair --- .../linear/queries/issue_by_title.graphql | 5 + .../linear/queries/issue_delete.graphql | 5 + .../linear/queries/issue_label_delete.graphql | 5 + .../linear/queries/project_by_name.graphql | 5 + .../linear/queries/project_create.graphql | 10 + .../linear/queries/project_delete.graphql | 5 + .../skills/linear/queries/team_states.graphql | 8 + .../src/orchestrator_run/backends.rs | 184 ++- .../src/orchestrator_run/config.rs | 18 + .../src/scheduler.rs | 114 +- .../tests/scheduler.rs | 258 +++- crates/opensymphony-workspace/src/manager.rs | 136 ++ .../tests/workspace_manager.rs | 70 ++ docs/implementation-plan.md | 7 +- docs/multi-repository-rollout.md | 145 +++ docs/operations.md | 19 +- .../osym-895-release-evidence.template.json | 30 + docs/testing-and-operations.md | 7 +- docs/workspace-and-lifecycle.md | 13 +- scripts/README.md | 10 + scripts/hermetic_multi_repo_lifecycle.sh | 121 ++ scripts/live_multi_repo_rollout.sh | 1118 +++++++++++++++++ 22 files changed, 2205 insertions(+), 88 deletions(-) create mode 100644 .agents/skills/linear/queries/issue_by_title.graphql create mode 100644 .agents/skills/linear/queries/issue_delete.graphql create mode 100644 .agents/skills/linear/queries/issue_label_delete.graphql create mode 100644 .agents/skills/linear/queries/project_by_name.graphql create mode 100644 .agents/skills/linear/queries/project_create.graphql create mode 100644 .agents/skills/linear/queries/project_delete.graphql create mode 100644 .agents/skills/linear/queries/team_states.graphql create mode 100644 docs/multi-repository-rollout.md create mode 100644 docs/tasks/evidence/osym-895-release-evidence.template.json create mode 100755 scripts/hermetic_multi_repo_lifecycle.sh create mode 100755 scripts/live_multi_repo_rollout.sh diff --git a/.agents/skills/linear/queries/issue_by_title.graphql b/.agents/skills/linear/queries/issue_by_title.graphql new file mode 100644 index 000000000..5dab6ffb4 --- /dev/null +++ b/.agents/skills/linear/queries/issue_by_title.graphql @@ -0,0 +1,5 @@ +query IssueByTitle($title: String!, $teamId: ID!) { + issues(filter: { title: { eq: $title }, team: { id: { eq: $teamId } } }, first: 2) { + nodes { id identifier title } + } +} diff --git a/.agents/skills/linear/queries/issue_delete.graphql b/.agents/skills/linear/queries/issue_delete.graphql new file mode 100644 index 000000000..5b85bb89a --- /dev/null +++ b/.agents/skills/linear/queries/issue_delete.graphql @@ -0,0 +1,5 @@ +mutation IssueDelete($id: String!) { + issueDelete(id: $id) { + success + } +} diff --git a/.agents/skills/linear/queries/issue_label_delete.graphql b/.agents/skills/linear/queries/issue_label_delete.graphql new file mode 100644 index 000000000..dd4c6434b --- /dev/null +++ b/.agents/skills/linear/queries/issue_label_delete.graphql @@ -0,0 +1,5 @@ +mutation IssueLabelDelete($id: String!) { + issueLabelDelete(id: $id) { + success + } +} diff --git a/.agents/skills/linear/queries/project_by_name.graphql b/.agents/skills/linear/queries/project_by_name.graphql new file mode 100644 index 000000000..76d30326d --- /dev/null +++ b/.agents/skills/linear/queries/project_by_name.graphql @@ -0,0 +1,5 @@ +query ProjectByName($name: String!) { + projects(filter: { name: { eq: $name } }, first: 2) { + nodes { id name slugId } + } +} diff --git a/.agents/skills/linear/queries/project_create.graphql b/.agents/skills/linear/queries/project_create.graphql new file mode 100644 index 000000000..b8be8002b --- /dev/null +++ b/.agents/skills/linear/queries/project_create.graphql @@ -0,0 +1,10 @@ +mutation ProjectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + id + name + slugId + } + } +} diff --git a/.agents/skills/linear/queries/project_delete.graphql b/.agents/skills/linear/queries/project_delete.graphql new file mode 100644 index 000000000..45ebe8a23 --- /dev/null +++ b/.agents/skills/linear/queries/project_delete.graphql @@ -0,0 +1,5 @@ +mutation ProjectDelete($id: String!) { + projectDelete(id: $id) { + success + } +} diff --git a/.agents/skills/linear/queries/team_states.graphql b/.agents/skills/linear/queries/team_states.graphql new file mode 100644 index 000000000..d38b87317 --- /dev/null +++ b/.agents/skills/linear/queries/team_states.graphql @@ -0,0 +1,8 @@ +query TeamStates($id: String!) { + team(id: $id) { + id + states { + nodes { id name type } + } + } +} diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index 0c6b86c13..bcdd4874f 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -1955,6 +1955,7 @@ impl RuntimeTrackerBackend { pr_url: &str, repository: &CheckoutRepository, expected_head_branch: Option<&str>, + issue_identifier: &str, ) -> Result, LinearError> { let url = Url::parse(pr_url).map_err(|error| { LinearError::InvalidResponse(format!("invalid GitHub pull request URL: {error}")) @@ -2076,7 +2077,11 @@ impl RuntimeTrackerBackend { .iter() .any(|candidate| candidate == provider_id) }) - && expected_head_branch.is_none_or(|expected| pull_request.head.ref_name == expected); + && github_head_branch_matches_issue( + &pull_request.head.ref_name, + expected_head_branch, + issue_identifier, + ); let merge_method_satisfied = if compatible && pull_request.merged_at.is_some() { let Some(satisfied) = self .github_merge_method_satisfied( @@ -2724,8 +2729,13 @@ query OpenSymphonyReviewThreads( let pull_requests = parent_pull_request_candidates(issue); let evidence = stream::iter(pull_requests) .map(|pr_url| async move { - self.github_merge_evidence(&pr_url, repository, issue.branch_name.as_deref()) - .await + self.github_merge_evidence( + &pr_url, + repository, + issue.branch_name.as_deref(), + &issue.identifier, + ) + .await }) .buffer_unordered(PARENT_ELIGIBILITY_PROVIDER_CONCURRENCY) .collect::>() @@ -3838,6 +3848,30 @@ impl GithubMergeEvidence { } } +fn github_head_branch_matches_issue( + head: &str, + suggested_branch: Option<&str>, + issue_identifier: &str, +) -> bool { + let Some(suggested_branch) = suggested_branch else { + return true; + }; + if head == suggested_branch { + return true; + } + + // Linear regenerates its suggested branch when an issue title changes. + // A stable semantic branch remains bound to the issue identifier. + let head = head.to_ascii_lowercase(); + let identifier = issue_identifier.to_ascii_lowercase(); + ["feat", "fix", "docs", "chore", "refactor", "test"] + .iter() + .any(|kind| { + let stem = format!("{kind}/{identifier}"); + head == stem || head.starts_with(&format!("{stem}-")) + }) +} + fn select_current_github_merge_evidence( candidates: Vec, ) -> ( @@ -3884,6 +3918,31 @@ impl GitHubRepository { } } +fn parent_workspace_reentry_allowed( + state: &DurableOrchestratorState, + parent_id: &IssueId, + snapshot: &HierarchySnapshot, +) -> bool { + use crate::opensymphony_orchestrator::ParentIntegrationState; + + snapshot.dispatch_claimed() + && snapshot.blocked_reason.is_none() + && state + .parent_integrations + .get(parent_id) + .is_some_and(|controller| { + controller.hierarchy_generation == snapshot.generation + && matches!( + controller.state, + ParentIntegrationState::RefreshingRepositories + | ParentIntegrationState::Integrating + | ParentIntegrationState::Fixing { .. } + | ParentIntegrationState::RefreshingAfterFixes + | ParentIntegrationState::FinalVerification + ) + }) +} + fn parent_checkout_requests( state: &DurableOrchestratorState, parent_id: &IssueId, @@ -4364,20 +4423,28 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { "parent preparation requires a pinned hierarchy snapshot".to_owned(), ) })?; - if !snapshot.dispatch_intended() { + let parent = if snapshot.dispatch_intended() { + let requests = parent_checkout_requests(&state, &issue.id, snapshot)?; + self.manager + .prepare_parent_execution_root( + &issue_descriptor(issue), + snapshot.generation, + requests, + ) + .await? + } else if parent_workspace_reentry_allowed(&state, &issue.id, snapshot) { + self.manager + .open_parent_execution_root_for_retry( + &issue_descriptor(issue), + snapshot.generation, + ) + .await? + } else { return Err(CliWorkspaceError::RetryState( - "parent preparation requires a persisted dispatch intent".to_owned(), + "parent preparation requires a persisted dispatch intent or active dispatch claim" + .to_owned(), )); - } - let requests = parent_checkout_requests(&state, &issue.id, snapshot)?; - let parent = self - .manager - .prepare_parent_execution_root( - &issue_descriptor(issue), - snapshot.generation, - requests, - ) - .await?; + }; self.terminal_cleanup_paths .remove(parent.handle.workspace_path()); return Ok(crate::opensymphony_domain::WorkspaceRecord { @@ -11074,6 +11141,63 @@ mod tests { assert!(parse_superseded_harness_manifests("{not-json").is_err()); } + #[test] + fn parent_workspace_reentry_requires_live_claimed_generation() { + use crate::opensymphony_orchestrator::{ + ParentIntegrationController, ParentIntegrationState, + }; + + let parent_id = IssueId::new("parent-id").expect("parent id"); + let mut snapshot = HierarchySnapshot { + parent_id: parent_id.clone(), + generation: 7, + required_child_edges: Vec::new(), + frozen: true, + blocked_reason: None, + eligibility_blocked_reason: None, + dispatched_generation: Some(7), + dispatch_intent_generation: None, + in_flight_generation: None, + dispatch_required_merge_commits: Vec::new(), + }; + let mut controller = + ParentIntegrationController::new(parent_id.clone(), 7).expect("controller"); + controller.state = ParentIntegrationState::Fixing { + repository_id: CanonicalRepositoryId::new("github:repository:a").expect("repo id"), + repair_attempt: 1, + }; + let mut state = DurableOrchestratorState::default(); + state + .parent_integrations + .insert(parent_id.clone(), controller); + assert!(parent_workspace_reentry_allowed( + &state, &parent_id, &snapshot + )); + + snapshot.dispatched_generation = None; + assert!(!parent_workspace_reentry_allowed( + &state, &parent_id, &snapshot + )); + snapshot.dispatched_generation = Some(7); + state + .parent_integrations + .get_mut(&parent_id) + .expect("controller") + .state = ParentIntegrationState::Completed; + assert!(!parent_workspace_reentry_allowed( + &state, &parent_id, &snapshot + )); + state + .parent_integrations + .get_mut(&parent_id) + .expect("controller") + .state = ParentIntegrationState::RefreshingRepositories; + snapshot.generation = 8; + assert!(!parent_workspace_reentry_allowed( + &state, &parent_id, &snapshot + )); + } + #[test] fn parent_checkout_requests_require_generation_bound_leases_and_scoped_merges() { let parent_id = IssueId::new("parent-id").expect("parent id"); @@ -16370,6 +16494,36 @@ Run the scheduler. assert!(!github_backed_review_provider("gitlab")); } + #[test] + fn github_merge_evidence_accepts_stable_semantic_issue_branch() { + let suggested = Some("leonardogonzalez/coe-666-renamed-title"); + assert!(github_head_branch_matches_issue( + "feat/COE-666-disposable-delivery", + suggested, + "COE-666" + )); + assert!(github_head_branch_matches_issue( + "leonardogonzalez/coe-666-renamed-title", + suggested, + "COE-666" + )); + assert!(!github_head_branch_matches_issue( + "feat/COE-667-disposable-delivery", + suggested, + "COE-666" + )); + assert!(!github_head_branch_matches_issue( + "feat/COE-6667-disposable-delivery", + suggested, + "COE-666" + )); + assert!(!github_head_branch_matches_issue( + "other/COE-666-disposable-delivery", + suggested, + "COE-666" + )); + } + #[test] fn codex_review_request_replay_keeps_the_pre_write_cursor() { use crate::opensymphony_orchestrator::{ diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index fa7905899..b1f7cf5d2 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -2787,6 +2787,24 @@ fn non_empty(value: &str) -> Option { mod tests { use super::*; + #[tokio::test] + async fn release_candidate_selected_central_config_validates() { + let Some(path) = env::var_os("OPENSYMPHONY_RELEASE_CONFIG") else { + return; + }; + let path = PathBuf::from(path); + let raw = fs::read_to_string(&path) + .await + .expect("selected config must be readable"); + assert!( + looks_like_central_config(&raw), + "release candidate must select a central config" + ); + load_central_config(&path) + .await + .expect("selected central config must validate"); + } + #[test] fn memory_bootstrap_is_required_when_auto_capture_is_enabled() { let repo = tempfile::tempdir().expect("temp repo should exist"); diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index a85452176..925664120 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -117,6 +117,44 @@ fn parent_eligibility_timeout(provider_work_units: usize) -> Duration { PARENT_ELIGIBILITY_TIMEOUT.saturating_mul(multiplier) } +fn full_detail_refresh_interval_ms(state: &DurableOrchestratorState) -> u64 { + // Summary discovery cannot admit a parent: it has no complete child-edge + // set. Once every required child is terminal, obtain that full snapshot on + // the terminal cadence instead of leaving fan-in behind the hourly scan. + // A parent retry also needs a full snapshot to refresh its verified targets. + let parent_waiting_for_refresh = state.parent_integrations.values().any(|controller| { + matches!( + controller.state, + super::ParentIntegrationState::RefreshingRepositories + ) + }); + let parent_ready_for_fan_in = state.hierarchy.iter().any(|(parent_id, snapshot)| { + state + .parent_integrations + .get(parent_id) + .is_some_and(|controller| { + matches!( + controller.state, + super::ParentIntegrationState::WaitingForChildren + ) + }) + && snapshot + .required_child_edges + .iter() + .any(|edge| edge.required) + && snapshot + .required_child_edges + .iter() + .filter(|edge| edge.required) + .all(|edge| state.terminal_orchestrator_issues.contains(&edge.child_id)) + }); + if parent_ready_for_fan_in || parent_waiting_for_refresh { + TERMINAL_REFRESH_INTERVAL_MS + } else { + FULL_DETAIL_REFRESH_INTERVAL_MS + } +} + fn workspace_key_changed_for_issue(execution: &IssueExecution, issue: &NormalizedIssue) -> bool { let Some(workspace) = execution.workspace() else { return false; @@ -2489,11 +2527,13 @@ where }); self.expire_linear_cooldown(observed_at); + let full_detail_refresh_interval_ms = + full_detail_refresh_interval_ms(&self.hierarchy_state); let mut pre_update_full_snapshot = if !self.linear_cooldown_active(observed_at) && (subtree_cleanup_pending || due( self.last_full_detail_refresh_at, - FULL_DETAIL_REFRESH_INTERVAL_MS, + full_detail_refresh_interval_ms, observed_at, )) { if let Some(tracker_snapshot) = self.load_tracker_snapshot(observed_at).await? { @@ -2585,7 +2625,7 @@ where if pre_update_full_snapshot.is_none() && due( self.last_full_detail_refresh_at, - FULL_DETAIL_REFRESH_INTERVAL_MS, + full_detail_refresh_interval_ms, observed_at, ) { @@ -10343,6 +10383,76 @@ mod tests { ); } + #[test] + fn parent_fan_in_and_refresh_states_shorten_full_detail_cadence() { + let mut parent = tracker_issue_from_normalized(&issue_with_project(None, None)); + let parent_id = IssueId::new(parent.id.clone()).expect("parent id"); + let child_ids = [ + IssueId::new("fan-in-child-a").expect("child id"), + IssueId::new("fan-in-child-b").expect("child id"), + ]; + parent.sub_issues = child_ids + .iter() + .map(|child_id| TrackerIssueRef { + id: child_id.to_string(), + identifier: child_id.to_string(), + title: None, + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }) + .collect(); + let snapshot = HierarchySnapshot::new(&parent); + let mut state = DurableOrchestratorState::default(); + state.parent_integrations.insert( + parent_id.clone(), + ParentIntegrationController::new(parent_id.clone(), snapshot.generation) + .expect("parent controller"), + ); + state.hierarchy.insert(parent_id.clone(), snapshot); + + assert_eq!( + full_detail_refresh_interval_ms(&state), + FULL_DETAIL_REFRESH_INTERVAL_MS + ); + state + .terminal_orchestrator_issues + .insert(child_ids[0].clone()); + assert_eq!( + full_detail_refresh_interval_ms(&state), + FULL_DETAIL_REFRESH_INTERVAL_MS, + "one completed child must not accelerate incomplete fan-in" + ); + state + .terminal_orchestrator_issues + .insert(child_ids[1].clone()); + assert_eq!( + full_detail_refresh_interval_ms(&state), + TERMINAL_REFRESH_INTERVAL_MS + ); + state + .parent_integrations + .get_mut(&parent_id) + .expect("controller") + .state = crate::opensymphony_orchestrator::ParentIntegrationState::Integrating; + assert_eq!( + full_detail_refresh_interval_ms(&state), + FULL_DETAIL_REFRESH_INTERVAL_MS, + "dispatched parents must not keep the fast full-refresh cadence" + ); + state + .parent_integrations + .get_mut(&parent_id) + .expect("controller") + .state = + crate::opensymphony_orchestrator::ParentIntegrationState::RefreshingRepositories; + assert_eq!( + full_detail_refresh_interval_ms(&state), + TERMINAL_REFRESH_INTERVAL_MS, + "parent retries need a fresh complete tracker snapshot" + ); + } + #[test] fn recovered_scheduler_worker_ids_expose_their_allocator_ordinal() { let recovered = WorkerId::new("scheduler-worker-7").expect("worker id should be valid"); diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index db0144a4b..7e7a2ed8d 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -1307,64 +1307,95 @@ async fn launched_parent_scheduler( async fn launched_parent_scheduler_with_config( suffix: &str, config: SchedulerConfig, +) -> (Scheduler, IssueId) { + launched_parent_scheduler_with_repositories(suffix, config, 1).await +} + +async fn launched_parent_scheduler_with_repositories( + suffix: &str, + config: SchedulerConfig, + repository_count: usize, ) -> (Scheduler, IssueId) { let parent_id = IssueId::new(format!("parent-{suffix}")).expect("parent id"); - let child_id = IssueId::new(format!("child-{suffix}")).expect("child id"); let parent_identifier = format!("COE-PARENT-{suffix}"); - let child_identifier = format!("COE-CHILD-{suffix}"); let mut parent = tracker_issue(parent_id.as_str(), &parent_identifier, "In Progress", 0); - parent.sub_issues = vec![TrackerIssueRef { - id: child_id.to_string(), - identifier: child_identifier, - title: Some("Child".to_owned()), - url: None, - state: "Done".to_owned(), - state_kind: TrackerIssueStateKind::Completed, - }]; + let children = (0..repository_count) + .map(|index| { + let key = if repository_count == 1 { + suffix.to_owned() + } else { + format!("{suffix}-{index}") + }; + let child_id = IssueId::new(format!("child-{key}")).expect("child id"); + let repository_id = CanonicalRepositoryId::new(format!("github:repository:{key}")) + .expect("repository id"); + let resource = LeaseResource { + issue_id: child_id.clone(), + repository_id: repository_id.clone(), + checkout_generation: format!("checkout-{key}"), + }; + (key, child_id, repository_id, resource) + }) + .collect::>(); + parent.sub_issues = children + .iter() + .map(|(key, child_id, _, _)| TrackerIssueRef { + id: child_id.to_string(), + identifier: format!("COE-CHILD-{key}"), + title: Some("Child".to_owned()), + url: None, + state: "Done".to_owned(), + state_kind: TrackerIssueStateKind::Completed, + }) + .collect(); let snapshot = HierarchySnapshot::new(&parent); - let repository_id = - CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"); - let resource = LeaseResource { - issue_id: child_id.clone(), - repository_id: repository_id.clone(), - checkout_generation: format!("checkout-{suffix}"), - }; let durable_state = crate::opensymphony_orchestrator::DurableOrchestratorState { hierarchy: BTreeMap::from([(parent_id.clone(), snapshot.clone())]), - leases: vec![LeaseRecord { - kind: LeaseKind::LeafWorker, - resource: resource.clone(), - owner: LeaseOwner::leaf_worker(&child_id), - hierarchy_generation: snapshot.generation, - acquired_at: 1, - expires_at: None, - released_at: None, - }], - terminal_orchestrator_issues: [child_id.clone()].into_iter().collect(), + leases: children + .iter() + .map(|(_, child_id, _, resource)| LeaseRecord { + kind: LeaseKind::LeafWorker, + resource: resource.clone(), + owner: LeaseOwner::leaf_worker(child_id), + hierarchy_generation: snapshot.generation, + acquired_at: 1, + expires_at: None, + released_at: None, + }) + .collect(), + terminal_orchestrator_issues: children + .iter() + .map(|(_, child_id, _, _)| child_id.clone()) + .collect(), ..Default::default() }; let evidence = ParentEligibilityEvidence { hierarchy_generation: snapshot.generation, - children: vec![ChildEligibilityEvidence { - child_id, - hierarchy_generation: snapshot.generation, - orchestrator_terminal: true, - provider_merge_confirmed: true, - merge_required: true, - merge_result_commit: Some(format!("commit-{suffix}")), - merge_result_commits: Vec::new(), - merge_result_commits_by_repository: vec![RequiredMergeCommit { - repository_id: Some(repository_id.clone()), - commit: format!("commit-{suffix}"), - }], - merge_repository_id: Some(repository_id.clone()), - merge_repository_ids: vec![repository_id.clone()], - provider_evidence_at: None, - provider_evidence_by_issue: Vec::new(), - resource: Some(resource), - resources: Vec::new(), - unresolved_failure: None, - }], + children: children + .iter() + .map( + |(key, child_id, repository_id, resource)| ChildEligibilityEvidence { + child_id: child_id.clone(), + hierarchy_generation: snapshot.generation, + orchestrator_terminal: true, + provider_merge_confirmed: true, + merge_required: true, + merge_result_commit: Some(format!("commit-{key}")), + merge_result_commits: Vec::new(), + merge_result_commits_by_repository: vec![RequiredMergeCommit { + repository_id: Some(repository_id.clone()), + commit: format!("commit-{key}"), + }], + merge_repository_id: Some(repository_id.clone()), + merge_repository_ids: vec![repository_id.clone()], + provider_evidence_at: None, + provider_evidence_by_issue: Vec::new(), + resource: Some(resource.clone()), + resources: Vec::new(), + unresolved_failure: None, + }, + ) + .collect(), }; let mut scheduler = Scheduler::new( FakeTracker { @@ -1374,16 +1405,21 @@ async fn launched_parent_scheduler_with_config( }, FakeWorkspace { durable_state: Some(serde_json::to_value(&durable_state).expect("durable state")), - parent_targets: vec![crate::opensymphony_orchestrator::ParentRepositoryTarget { - repository_id, - checkout_handle: format!("checkout-{suffix}"), - relative_path: PathBuf::from(format!("repositories/{suffix}")), - target_branch: "develop".to_owned(), - target_commit: format!("commit-{suffix}"), - instruction_path: PathBuf::from("AGENTS.md"), - instruction_hash: format!("instruction-{suffix}"), - repair_policy: test_repair_policy(), - }], + parent_targets: children + .iter() + .map(|(key, _, repository_id, resource)| { + crate::opensymphony_orchestrator::ParentRepositoryTarget { + repository_id: repository_id.clone(), + checkout_handle: resource.checkout_generation.clone(), + relative_path: PathBuf::from(format!("repositories/{key}")), + target_branch: "develop".to_owned(), + target_commit: format!("commit-{key}"), + instruction_path: PathBuf::from("AGENTS.md"), + instruction_hash: format!("instruction-{key}"), + repair_policy: test_repair_policy(), + } + }) + .collect(), ..Default::default() }, FakeWorker::default(), @@ -1793,11 +1829,28 @@ async fn parent_repair_review_merge_and_refresh_follow_durable_policy() { async fn scheduler_tick_drives_failed_parent_verification_through_repair_and_back_to_verification() { let suffix = "REPAIR-RUN-LOOP"; - let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; + let (mut scheduler, parent_id) = + launched_parent_scheduler_with_repositories(suffix, scheduler_config(), 3).await; + assert_eq!(scheduler.workspace().parent_targets.len(), 3); + let initial_targets = scheduler.workspace().parent_targets.clone(); + let launched: crate::opensymphony_orchestrator::DurableOrchestratorState = + serde_json::from_value(scheduler.workspace().durable_state.clone().expect("state")) + .expect("durable state"); + let controller = &launched.parent_integrations[&parent_id]; + assert_eq!(controller.targets.len(), 3); + for target in &initial_targets { + assert_eq!( + controller.targets[&target.repository_id].target_commit, target.target_commit, + "the scheduler must preserve each child merge commit at parent launch" + ); + } let repository_id = scheduler.workspace().parent_targets[0] .repository_id .clone(); - scheduler.workspace_mut().repair_instruction_hash = Some(format!("instruction-{suffix}")); + let instruction_hash = scheduler.workspace().parent_targets[0] + .instruction_hash + .clone(); + scheduler.workspace_mut().repair_instruction_hash = Some(instruction_hash); scheduler.tracker_mut().repair_pull_request = Some(( "41".to_owned(), "https://github.com/acme/repo/pull/41".to_owned(), @@ -1945,6 +1998,19 @@ async fn scheduler_tick_drives_failed_parent_verification_through_repair_and_bac None, "the post-merge turn is final verification, not repair implementation" ); + let mut terminal = tracker_state_snapshot( + parent_id.as_str(), + &format!("COE-PARENT-{suffix}"), + "Done", + "completed", + 20_000_040, + ); + terminal.is_parent = true; + scheduler.tracker_mut().active.clear(); + scheduler + .tracker_mut() + .states + .insert(parent_id.to_string(), terminal); enqueue_successful_parent_completion(&mut scheduler, suffix, 20_000_050); scheduler .tick(ts(20_000_050)) @@ -1959,10 +2025,55 @@ async fn scheduler_tick_drives_failed_parent_verification_through_repair_and_bac Some(crate::opensymphony_orchestrator::ParentAttemptStatus::Passed) ); assert_eq!(controller.repair_attempts.len(), 1); + assert_eq!(controller.targets.len(), 3); + for target in &initial_targets[1..] { + assert_eq!( + controller.targets[&target.repository_id].target_commit, target.target_commit, + "repair must not refresh an unaffected repository" + ); + } + scheduler + .acknowledge_terminal_capture(&[format!("COE-PARENT-{suffix}")], ts(20_000_070)) + .await + .expect("capture should release all three child generations and the parent"); + let cleaned = &scheduler.workspace().generation_cleanups; + assert_eq!(cleaned.len(), 4); + assert_eq!( + cleaned.last().expect("parent cleanup").issue_id, + parent_id.as_str() + ); + for target in &initial_targets { + assert!( + scheduler + .workspace() + .cleanup_target_requests + .iter() + .any(|resource| { + resource.repository_id == target.repository_id + && resource.checkout_generation == target.checkout_handle + }) + ); + } } -#[tokio::test] -async fn terminal_parent_between_repair_turns_cancels_and_persists_its_controller() { +#[test] +fn terminal_parent_between_repair_turns_cancels_and_persists_its_controller() { + // This composed durable-state fixture needs more than the Linux test thread's 2 MiB stack. + std::thread::Builder::new() + .stack_size(4 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(terminal_parent_cancellation_case()); + }) + .expect("test thread") + .join() + .expect("test thread should not panic"); +} + +async fn terminal_parent_cancellation_case() { let suffix = "REPAIR-WAIT-CANCEL"; let (mut scheduler, parent_id) = launched_parent_scheduler(suffix).await; let repository_id = scheduler.workspace().parent_targets[0] @@ -2959,7 +3070,7 @@ async fn parent_repair_recovers_review_and_merge_results_without_duplicate_write fn enqueue_successful_parent_completion( scheduler: &mut Scheduler, - suffix: &str, + _suffix: &str, finished_at: u64, ) { let run = scheduler @@ -3022,17 +3133,27 @@ fn enqueue_successful_parent_completion( .get(&run.issue_id) .expect("parent controller") .hierarchy_generation; - let repository_id = - CanonicalRepositoryId::new(format!("github:repository:{suffix}")).expect("repository id"); + let repository_id = state.parent_integrations[&run.issue_id] + .targets + .keys() + .next() + .expect("parent target") + .clone(); let target_commit = state.parent_integrations[&run.issue_id].targets[&repository_id] .target_commit .clone(); - outcome.parent_verification = Some(parent_verification_evidence( + let mut evidence = parent_verification_evidence( run.worker_id.as_str(), hierarchy_generation, repository_id, &target_commit, - )); + ); + evidence.repository_commits = state.parent_integrations[&run.issue_id] + .targets + .iter() + .map(|(repository_id, target)| (repository_id.clone(), target.target_commit.clone())) + .collect(); + outcome.parent_verification = Some(evidence); scheduler .worker_mut() .updates @@ -3098,6 +3219,11 @@ fn enqueue_failed_parent_repair_request( repository_id.clone(), &target_commit, ); + evidence.repository_commits = state.parent_integrations[&run.issue_id] + .targets + .iter() + .map(|(repository_id, target)| (repository_id.clone(), target.target_commit.clone())) + .collect(); evidence.repair_repository_id = Some(repository_id); let mut outcome = WorkerOutcomeRecord::from_run( &run, diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index d3a83d586..440ab02bf 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -864,6 +864,21 @@ impl WorkspaceManager { .await } + pub async fn open_parent_execution_root_for_retry( + &self, + issue: &IssueDescriptor, + hierarchy_generation: u64, + ) -> Result { + let root = self + .config + .root + .join("parents") + .join(parent_workspace_key(&issue.identifier, &issue.issue_id)?) + .join(hierarchy_generation.to_string()); + self.open_parent_execution_root_at_for_retry(issue, &root) + .await + } + pub async fn open_parent_execution_root_at_for_repair( &self, issue: &IssueDescriptor, @@ -4812,6 +4827,10 @@ impl WorkspaceManager { path: workspace.workspace_path().to_path_buf(), generation: request.generation.clone(), })?; + self.release_parent_child_checkout_pins(workspace, &request.generation) + .await?; + self.release_checkout_staging_intent(workspace, &request.generation) + .await?; if tombstone.deleted_at.is_none() { tombstone.deleted_at = Some(Utc::now()); self.write_cleanup_tombstone(workspace, &tombstone).await?; @@ -4847,6 +4866,10 @@ impl WorkspaceManager { }); } } + self.release_parent_child_checkout_pins(workspace, &request.generation) + .await?; + self.release_checkout_staging_intent(workspace, &request.generation) + .await?; tombstone.deleted_at = Some(Utc::now()); self.write_cleanup_tombstone(workspace, &tombstone).await?; return Ok(CleanupOutcome { @@ -4923,6 +4946,10 @@ impl WorkspaceManager { }); } } + self.release_parent_child_checkout_pins(workspace, &tombstone.generation) + .await?; + self.release_checkout_staging_intent(workspace, &tombstone.generation) + .await?; tombstone.deleted_at = Some(Utc::now()); self.write_cleanup_tombstone(workspace, &tombstone).await?; Ok(CleanupOutcome { @@ -5951,6 +5978,115 @@ impl WorkspaceManager { } } + async fn release_parent_child_checkout_pins( + &self, + workspace: &WorkspaceHandle, + generation: &str, + ) -> Result<(), WorkspaceError> { + let Some(number) = generation.strip_prefix("parent:") else { + return Ok(()); + }; + let hierarchy_generation = + number + .parse::() + .map_err(|_| WorkspaceError::CleanupGenerationMismatch { + path: workspace.workspace_path().to_path_buf(), + expected: "parent:".to_owned(), + actual: generation.to_owned(), + })?; + let root = self.canonicalize_path(&self.config.root).await?; + let relative_dir = + PathBuf::from(".opensymphony-parent-pins").join(workspace.workspace_key()); + let directory = resolve_path_within_root(&root, &relative_dir)?; + self.reject_symlinked_path_components(&root, &relative_dir) + .await?; + for name in [ + format!("{hierarchy_generation}.json"), + format!("{hierarchy_generation}.refresh.json"), + ] { + let path = directory.join(name); + match fs::remove_file(&path).await { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(source) => return Err(WorkspaceError::RemoveWorkspace { path, source }), + } + } + match fs::remove_dir(&directory).await { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::DirectoryNotEmpty + ) => {} + Err(source) => { + return Err(WorkspaceError::RemoveWorkspace { + path: directory, + source, + }); + } + } + Ok(()) + } + + async fn release_checkout_staging_intent( + &self, + workspace: &WorkspaceHandle, + generation: &str, + ) -> Result<(), WorkspaceError> { + let Some(checkout_generation) = workspace.checkout_generation() else { + return Ok(()); + }; + if checkout_generation != generation { + return Err(WorkspaceError::CleanupGenerationMismatch { + path: workspace.workspace_path().to_path_buf(), + expected: checkout_generation.to_owned(), + actual: generation.to_owned(), + }); + } + let root = self.canonicalize_path(&self.config.root).await?; + let relative = PathBuf::from(".opensymphony-staging").join(format!( + "{}--{generation}.intent.json", + workspace.workspace_key() + )); + let marker_path = resolve_path_within_root(&root, &relative)?; + self.reject_symlinked_path_components(&root, &relative) + .await?; + match fs::symlink_metadata(&marker_path).await { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(WorkspaceError::ReadManagedFile { + path: marker_path, + source, + }); + } + Ok(metadata) if !metadata.is_file() => { + return Err(checkout_verification( + &marker_path, + "checkout staging intent is not a regular file", + )); + } + Ok(_) => {} + } + let staging_root = root.join(".opensymphony-staging"); + let canonical_staging_root = self.canonicalize_path(&staging_root).await?; + if !self + .staging_intent_is_orphaned(&canonical_staging_root, &marker_path) + .await? + { + return Err(checkout_verification( + &marker_path, + "checkout staging intent does not match the deleted generation", + )); + } + fs::remove_file(&marker_path) + .await + .map_err(|source| WorkspaceError::RemoveWorkspace { + path: marker_path, + source, + })?; + Ok(()) + } + pub async fn load_conversation_manifest( &self, workspace: &WorkspaceHandle, diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index f42e47747..78515684e 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -561,6 +561,21 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( .and_then(|record| record.merge_method.as_deref()), Some("squash") ); + let retried = manager + .open_parent_execution_root_for_retry(&parent, 5) + .await + .expect("a dispatched parent should reopen its verified generation"); + assert_eq!( + retried.handle.workspace_path(), + prepared.handle.workspace_path() + ); + assert!( + manager + .open_parent_execution_root_for_retry(&parent, 9) + .await + .is_err(), + "a retry cannot create another parent generation" + ); let repository_a = prepared .child_checkout_map .repositories @@ -1154,11 +1169,29 @@ async fn parent_execution_root_reuses_three_repositories_and_preserves_children( "terminal cleanup must remove the Git worktree registration" ); } + let cleanup_pin_dir = manager + .config() + .root + .join(".opensymphony-parent-pins") + .join(cleanup_parent.handle.workspace_key()); + let pin = cleanup_pin_dir.join("9.json"); + assert!(pin.exists()); + std::fs::copy(&pin, cleanup_pin_dir.join("9.refresh.json")) + .expect("simulate a leftover refresh transaction"); manager .cleanup_target(&cleanup_target) .await .expect("terminal parent cleanup should remove its prepared root"); assert!(!cleanup_parent.handle.workspace_path().exists()); + assert!(!pin.exists(), "terminal cleanup releases the checkout pin"); + assert!( + !cleanup_pin_dir.join("9.refresh.json").exists(), + "terminal cleanup releases the pending refresh copy" + ); + manager + .cleanup_target(&cleanup_target) + .await + .expect("terminal cleanup is idempotent after pin release"); manager .prepare_parent_execution_root(&parent, 9, repeat_requests.clone()) .await @@ -2340,6 +2373,43 @@ async fn verified_checkout_is_atomic_repository_local_and_quarantines_drift() { std::fs::canonicalize(hooked.handle.workspace_path()).expect("checkout should exist") ); + let generation = hooked + .handle + .checkout_generation() + .expect("verified checkout should have a generation") + .to_owned(); + let staging_intent = hook_manager + .config() + .root + .join(".opensymphony-staging") + .join(format!( + "{}--{generation}.intent.json", + hooked.handle.workspace_key() + )); + assert!( + staging_intent.is_file(), + "published checkout retains its recovery intent" + ); + let cleanup = CleanupRequest { + generation, + outcome: CleanupTerminalOutcome::Succeeded, + remove: true, + }; + hook_manager + .cleanup_with_request( + &hooked.handle, + IssueLifecycleState::Terminal, + cleanup.clone(), + ) + .await + .expect("terminal cleanup should release the checkout and its staging intent"); + assert!(!hooked.handle.workspace_path().exists()); + assert!(!staging_intent.exists()); + hook_manager + .cleanup_with_request(&hooked.handle, IssueLifecycleState::Terminal, cleanup) + .await + .expect("terminal cleanup should stay idempotent after staging-intent release"); + assert!(!clean_retry.handle.workspace_path().exists()); assert!( temp_dir diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index a6e03c613..bfca2e7c8 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -256,8 +256,11 @@ They first replace implicit repository scope with central config, canonical task bindings, verified checkouts, and one per-instance memory service; then add durable parent leases, child-storage reuse, integration, repair, and cleanup; finally they expose truthful operator state and gate activation on deterministic -fault-injected validation. The wave reuses completed COE-448 memory-server work -and M12.9 code snapshots/overlays instead of creating parallel implementations. +fault-injected validation. M12.97 uses the composed hermetic gate and isolated +rollout procedure in `docs/multi-repository-rollout.md`; production activation +remains a separate operator decision. The wave reuses completed COE-448 +memory-server work and M12.9 code snapshots/overlays instead of creating +parallel implementations. M12.99 follows `docs/specs/acp-harness-adapter.md`. It delivers a working generic ACP runtime through the real `opensymphony run` path, including configured diff --git a/docs/multi-repository-rollout.md b/docs/multi-repository-rollout.md new file mode 100644 index 000000000..3fe009957 --- /dev/null +++ b/docs/multi-repository-rollout.md @@ -0,0 +1,145 @@ +# Multi-repository release gate and isolated rollout + +Strict multi-repository routing stays disabled for production projects until the +hermetic gate and one disposable non-production run pass at the same immutable +commit, with each selected central config identified by its SHA-256. + +## Hermetic gate + +Run: + +```bash +OPENSYMPHONY_RELEASE_CONFIG=/absolute/path/to/candidate-config.yaml \ + scripts/hermetic_multi_repo_lifecycle.sh +``` + +The gate uses local temporary repositories and fake tracker, provider, and +harness implementations. The workspace fixture creates three local bare remotes +with different instructions, retains child generations, and prepares parent +worktrees from child object stores. The three-repository scheduler scenario +carries its repository IDs, child merge identities, and retained generation +handles through one selected repair, provider PR and merge, final verification, +capture, and bottom-up cleanup. The workspace fixture separately verifies Git +worktree detachment and generation-bound cleanup. Scheduler fixtures serialize +durable state around intents and receipts and reconcile provider state before +repeating a side effect. + +The output `release-evidence.json` records the exact Git commit, selected config +path and SHA-256, completion time, log path, and a `production_activation:false` +claim. A dirty working tree is useful during development, but rollout evidence +is accepted only when the recorded commit is the checked-out clean `HEAD` and CI +passes for that commit. + +Copy `docs/tasks/evidence/osym-895-release-evidence.template.json` into the +release record and fill it from the generated hermetic and live artifacts. The +release reviewer checks the candidate commit and config hashes, green CI, +requested-change repair, parent merge, teardown receipt, explicit +non-production project set, and `production_activation:false` before activation. + +## Numbered lifecycle and fault matrix + +| Boundary | Durable transition or side effect | Hermetic evidence | Injected failures and typed result | +|---|---|---|---| +| H01 | Resolve the selected central config and routing inventory | `central_config_*` | unknown fields, overlapping roots, invalid secrets/paths, unsupported provider and project scope fail validation | +| H02 | Preflight, apply, activate, and roll back legacy state | migration tests plus `apply_and_rollback_restore_legacy_files` | conflicts, edited inputs, interrupted catalog copy, active writer, invalid backup and rollback drift remain recoverable | +| H03 | Claim the runtime/state/catalog roots | `runtime_root_ownership_*`, `strict_run_marker_*`, memory coordination tests | a live owner blocks a second process; stale or malformed ownership is handled without stealing a live root | +| H04 | Resolve terminal-child repository bindings | scheduler binding tests and config routing tests | missing, unknown, multiple, disallowed, parent, out-of-scope, stale, and changed bindings remain distinct blocked/superseded states | +| H05 | Materialize and run three child checkouts | `parent_execution_root_reuses_three_repositories_and_preserves_children` plus harness/session suites | wrong remote, dirty checkout, stale generation, unsafe Git configuration, instruction mismatch, and malformed runtime evidence block before attach | +| H06 | Record provider-confirmed child merges, freeze hierarchy, and acquire ancestor leases | hierarchy/scheduler parent admission tests | missing or stale merge evidence and post-freeze hierarchy changes block without releasing required or higher-owner leases | +| H07 | Prepare contained parent integration worktrees from retained generations | three-repository workspace fixture | no fresh clone, no instruction leak, no path escape, no stale handle, and no cleanup while an ancestor lease is active | +| H08 | Run bounded parent checks and authorize memory | parent runtime-envelope, memory-grant, overlay, and base-commit tests | process timeout/indeterminate cleanup is retryable; unrelated live overlay and widened repository/work-item scope are denied | +| H09 | Persist one affected-repository repair intent, branch, push, and PR | `parent_repair_*` side-effect ledgers | provider outage, external closure, force push, stale receipt, and persistence loss remain resumable without duplicate branch, push, or PR | +| H10 | Apply requested changes, review, checks, and merge | requested-change and current-head provider tests | failed checks, rejected/stale review, exhausted review budget, missing merge intent, and merge conflict block merge without duplicating review or merge writes | +| H11 | Refresh every repository and record final verification/capture | repair refresh, exact run/commit receipt, capture retry, and memory provenance tests | stale target, missing child reachability, wrong conversation, malformed evidence, and capture failure preserve the controller for retry | +| H12 | Detach Git worktrees, release leases bottom-up, and delete eligible roots | subtree cleanup and three-repository cleanup tests | cleanup failure resumes only incomplete receipts; stale generations and active or higher-ancestor leases prevent deletion | +| H13 | Project state through domain, gateway, TUI, web, and desktop | Rust projection/round-trip tests and TypeScript contract/build checks | unavailable facts remain absent, secrets and exact host paths stay private, and blocked/releasing states do not render as completed | + +Every side-effecting scheduler path persists an intent before the write and a +receipt after it. The gate pairs each boundary with restart/reconciliation tests: +branch, push, PR, review, merge, capture, refresh, detach, lease release, and +cleanup counters remain one after replay. The workspace fixture also recovers an +interrupted refresh transaction before accepting the parent root. + +## Disposable non-production run + +The live gate runs the hermetic suite against its generated central config and +records that config hash before starting the isolated orchestrator: + +Provide `GH_TOKEN` for the configured GitHub owner with permission to create +private repositories, push workflow files, and delete those repositories. +`LINEAR_API_KEY` is also required. The GitHub token provisions and tears down +the fixture; it is not a reviewer credential. + +```bash +OPENSYMPHONY_LIVE_MULTI_REPO=1 \ +OPENSYMPHONY_LIVE_GITHUB_OWNER= \ +OPENSYMPHONY_LIVE_LINEAR_TEAM_ID= \ +OPENSYMPHONY_LIVE_MODEL= \ + scripts/live_multi_repo_rollout.sh +``` + +The script creates uniquely named private repositories, a uniquely named Linear +project and hierarchy, isolated state/workspace roots, and a unique +loopback port. It preflights the team's `Todo`, `Human Review`, `Rework`, and +`Done` states before provisioning and starts the overall deadline before the +first remote write. It caps task count, retry count, and model turns. The +alpha fixture uses a required GitHub Actions check to exercise a failed-check +rework loop on the same PR branch. The rework requirement is placed in the +issue title so the resumed worker receives it in continuation guidance. A +parent-only integration command checks `answer=42` in each verified checkout, +making alpha's seeded defect observable before repair. The disposable repositories +exercise failed-check rework with provider-verifiable merge commits instead of +an automated PR-review webhook. Review of this repository's PR follows the +configured OpenHands or Codex integration; the fixture +does not require a separate reviewer account. Each child calls its checked-in +completion helper as its last action to verify the edit. The rollout controller +requires a durable successful, stopped run receipt before moving the issue to +`Human Review`; the isolated two-minute scheduler tick gives this transition +time to land before any continuation dispatch. Alpha's rework requires a new +successful receipt after the initial failed-check run. The controller publishes +child edits on an issue-bound `feat/` branch from a fresh checkout using its +GitHub credential; the worker never receives the checkout credential. Resource +names are recorded before creation +and reconciled during teardown if a provider response is lost. The script tears +down processes, branches, pull requests, issues, project, repositories, port +ownership, credential copies, and local roots, then writes a teardown inventory. +A missing cleanup receipt fails the gate. + +The disposable private repositories have no branch protection. Their GitHub +plan returns 403 for the required-status-checks protection endpoint, so the +generated review profile leaves `required_checks` disabled. The rollout +controller instead verifies that every check run on each exact child PR head is +green before merging. It confirms that the merged `develop` branch contains the +child's exact delivery, removes only those published untracked fixture files +from the stopped worker checkout, and then moves the child to `Done`. This +leaves the retained checkout clean for parent workspace preparation. Production +review profiles retain their configured provider policy. + +The fixture keeps separate stable seed checkouts as memory sources while the +execution path uses verified per-issue clones. Before marking a child `Done`, +the controller advances its seed to the merged `develop` commit so terminal +memory capture has the current repository source. Parent subtree cleanup waits +through two terminal refresh windows, then requires no managed workspace or +parent pin to remain. Durable cleanup tombstones are receipts, not live +workspaces; the disposable state root containing them is removed during final +teardown. The active process lock is checked during final teardown. + +The active project set in that generated config contains only the disposable +Linear project and the three disposable repositories. The release evidence must +record its exact config hash before `opensymphony run` starts. Production project +sets remain unchanged. + +## Rollback and staged expansion + +Stop the isolated instance, verify it has no unresolved multi-repository run, +and switch its selected central config back to `legacy_single`. If the config +came from migration, run `opensymphony migrate rollback --config ` only +after the strict-run marker is absent. Preserve the catalog, controller state, +manifests, provider receipts, retained generations, and teardown report until +rollback verification completes. + +Expansion uses a new reviewed config generation for each project set. Re-run the +hermetic gate and one disposable live run at the exact candidate commit/config +hash, enable one explicit non-production project set, observe a complete clean +lifecycle, and then make a separate operator decision for any additional set. +Production activation is outside this gate. diff --git a/docs/operations.md b/docs/operations.md index 833f0fd48..c5e47354f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -475,7 +475,10 @@ Operational implications: - `opensymphony run` keeps its local worker/snapshot tick every 5s, while Linear reads use cheaper internal cadences: running state every 30s, dispatch discovery every 60s, terminal cleanup every 5 minutes, and full - issue details hourly after startup/dispatch + issue details hourly after startup/dispatch. When all required children of a + waiting parent have terminal orchestrator outcomes, full details refresh on + the 5-minute terminal cadence so parent eligibility can use a complete + hierarchy observation - if Linear returns a long rate-limit reset, the scheduler pauses all Linear reads behind one shared cooldown but continues processing worker updates; the same cooldown also suppresses later parent-provider eligibility lookups in @@ -818,6 +821,9 @@ continuation. A repair request is accepted only when its receipt selects a completed command observed by the harness before the attempt deadline. Repair implementation retries stop at the configured scheduler limit, and provider rate-limit responses defer all repair lookups until their retry delay expires. +While a parent is refreshing repositories, the scheduler requests complete +tracker details on the terminal refresh cadence so a failed verification can +retry without waiting for the hourly background scan. After a provider merge, refresh fetches the configured target, proves the recorded merge-result commit and every retained child merge result are reachable, and refreshes complete instruction provenance before final @@ -826,6 +832,10 @@ call selects merge, squash, or rebase centrally. Historical child merge evidence can prove a merge commit from its multi-parent topology; GitHub does not expose enough evidence to distinguish squash from rebase by a single-parent commit alone, so an ambiguous result remains ineligible. +Child merge evidence accepts either Linear's suggested head branch or a stable +semantic branch beginning with the child's issue identifier, such as +`feat/COE-666-delivery`. Linear can change its suggested branch when the issue +title changes; an unrelated issue's branch remains ineligible. Strict `opensymphony rehydrate` also derives the desired repository, harness, model, and generation envelope from the current central routing inventory before @@ -881,6 +891,13 @@ Activation markers are namespaced by the absolute central-config destination, so separate instances cannot overwrite or consume one another's rollback record. +Before enabling strict routing for any project set, follow +`docs/multi-repository-rollout.md`. The hermetic gate must pass at the clean +candidate commit and selected config hash. The first enabled set must be the +script-created disposable non-production set; its process, provider, tracker, +port, credential-copy, and workspace cleanup receipts are part of the release +evidence. Production project sets are enabled only by a later operator decision. + If an older target repo still contains `openhands.mcp`, remove that block. OpenSymphony 1.0.0 expects Linear access through `LINEAR_API_KEY` and the repo-local GraphQL helper assets copied by `opensymphony init`. diff --git a/docs/tasks/evidence/osym-895-release-evidence.template.json b/docs/tasks/evidence/osym-895-release-evidence.template.json new file mode 100644 index 000000000..bbdaa4b8e --- /dev/null +++ b/docs/tasks/evidence/osym-895-release-evidence.template.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "candidate": { + "commit_sha": "", + "central_config_sha256": "" + }, + "hermetic": { + "run_id": "", + "result": "passed", + "requested_change_repair": true, + "evidence_path": "", + "ci_url": "" + }, + "live": { + "run_id": "", + "project_set": "", + "result": "passed", + "failed_check_rework": true, + "parent_repair_merge": true, + "teardown_complete": true, + "evidence_path": "", + "teardown_path": "" + }, + "activation": { + "project_set": "", + "production_activation": false, + "operator": "", + "approved_at": "" + } +} diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index bcf59052b..fa7153c16 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -127,6 +127,11 @@ Current implementation: - `cargo test --lib orchestrator_run::backends::tests` covers runtime workspace-manifest recovery, in-flight run detection from `run.json`, and launch-path failure handling in the concrete CLI adapter - `cargo test --test workspace_manager` covers durable checkout/staging ownership-marker sweeps, preservation of foreign generation-shaped directories and staging content, receipt-owned recovery, and the retry verification mode that permits legitimate worker changes while retaining checkout provenance checks - `cargo test --lib opensymphony_workspace::manager::tests::discover_agents` and the memory scope tests cover bounded tracked-instruction probes, failure propagation, canonical project-ID filtering, and project-scoped direct capsule reads +- `scripts/hermetic_multi_repo_lifecycle.sh` is the M12.97 release gate. It + composes the inherited strict-config, migration/rollback, three-bare-repository + workspace, scheduler/provider restart, memory-scope, gateway/TUI, and + web/desktop suites and writes exact commit/config-hash evidence. The numbered + boundary and fault mapping lives in `docs/multi-repository-rollout.md`. - `cargo test --lib orchestrator_run::backends::tests` covers deferred cross-harness retirement so a failed replacement keeps the previous session recoverable - `tests/doctor.rs` runs the CLI live-probe path against the internal `opensymphony_testkit` module - `scripts/smoke_local.sh` runs the static doctor pass @@ -329,7 +334,7 @@ and login -> Enable device code authorization for Codex before retrying. Current repository implementation: -- `tests/scheduler.rs` covers continuation retry, failure backoff, cached per-state dispatch limits across finish/stall/inactive/terminal/reconciliation transitions, runtime-event-fed stall detection, terminal reconciliation with cleanup, Linear read cooldown and cadence, lightweight running-state refresh, and manifest-backed workspace recovery using fake backends +- `tests/scheduler.rs` covers continuation retry, failure backoff, cached per-state dispatch limits across finish/stall/inactive/terminal/reconciliation transitions, runtime-event-fed stall detection, terminal reconciliation with cleanup, Linear read cooldown and cadence, lightweight running-state refresh, and manifest-backed workspace recovery using fake backends. Scheduler unit tests also cover the shorter full-detail refresh cadence for completed parent fan-in - local restart validation should confirm that `opensymphony run` publishes a recovered snapshot before the first post-restart launch wave, so the TUI issue list repopulates even when reused conversations still take time to attach - `crates/opensymphony-cli/src/orchestrator_run/backends.rs` covers immediate launch-failure cleanup and abort-on-drop cleanup for tracked runtime worker tasks in the production CLI adapter diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index 65f7b61a5..c2229bc75 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -74,7 +74,10 @@ an atomic rename. Existing generations are reused only after the same checks; remote, branch, HEAD, instruction, or cleanliness drift is quarantined rather than reset in place. Startup cleanup also requires the matching durable staging-intent marker created before a clone; unrelated files and directories -under `.opensymphony-staging` are preserved. +under `.opensymphony-staging` are preserved. Terminal deletion releases the +matching checkout staging intent after removing the published generation and +before completing its cleanup tombstone. A failed release remains retryable +through that tombstone, including after the checkout path is gone. The checkout manifest records the generation, binding, target commit, instruction path/hash/source commit, scheduler-policy generation, resolved @@ -155,6 +158,14 @@ parent runtime root and requires the runtime-visible map to match it on every reopen. A parent turn therefore cannot authorize an older integration target by rewriting its local map. Reopen also repeats provider merge-result reachability checks against the pinned target. +Terminal parent cleanup removes the generation's external checkout pin and any +pending refresh copy after the parent root is deleted. Pin removal participates +in the durable cleanup retry, so a leftover pin cannot receive a completed +cleanup acknowledgement. +After the first dispatch consumes its intent, a parent retry opens that same +generation-bound root through the retry verifier. It requires a current durable +dispatch claim and an active parent integration state; it cannot prepare a new +root from a retry or revive a terminal parent. Parent roots follow the same `after_create` contract as leaf workspaces. A new empty root runs the configured hook before metadata bootstrap, writes the diff --git a/scripts/README.md b/scripts/README.md index 208b27872..0c8ab5194 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,3 +6,13 @@ Current scripts: - `smoke_local.sh`: runs the static `opensymphony doctor` preflight against `examples/configs/local-dev.yaml`. - `live_e2e.sh`: runs the opt-in live local suite. It executes the live `doctor` preflight, launches the pinned local OpenHands server, runs the ignored `live_local_suite` integration tests, and writes logs plus scenario summaries under `target/live-local//` unless `OPENSYMPHONY_LIVE_SUITE_OUTPUT_ROOT` overrides that root. +- `hermetic_multi_repo_lifecycle.sh`: composes the inherited strict-config, + migration, workspace, scheduler, provider, memory, gateway, TUI, web, and + desktop regressions into the deterministic multi-repository release gate. It + writes immutable commit/config-hash evidence below + `target/multi-repo-lifecycle//`. +- `live_multi_repo_rollout.sh`: opt-in bounded rollout against uniquely named + private GitHub repositories and a disposable Linear project. It exercises + failed-check rework; automated review of this repository's PR follows + `WORKFLOW.md`. It records teardown evidence below + `target/live-multi-repo//`. diff --git a/scripts/hermetic_multi_repo_lifecycle.sh b/scripts/hermetic_multi_repo_lifecycle.sh new file mode 100755 index 000000000..8c303b0c9 --- /dev/null +++ b/scripts/hermetic_multi_repo_lifecycle.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")"/.. && pwd)" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OUTPUT_ROOT="${OPENSYMPHONY_HERMETIC_OUTPUT_ROOT:-${ROOT_DIR}/target/multi-repo-lifecycle}" +RUN_DIR="${OUTPUT_ROOT%/}/${RUN_ID}" +LOG_FILE="${RUN_DIR}/gate.log" +CONFIG_PATH="${OPENSYMPHONY_RELEASE_CONFIG:-}" + +mkdir -p "${RUN_DIR}" +cd "${ROOT_DIR}" +export GIT_CONFIG_GLOBAL=/dev/null +export RUST_TEST_THREADS=1 +if [[ -n "${TMPDIR:-}" ]]; then + export TMPDIR="$(cd -- "${TMPDIR}" && pwd -P)/" +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Release evidence requires a clean Git worktree." >&2 + exit 1 +fi + +if [[ -z "${CONFIG_PATH}" || ! -f "${CONFIG_PATH}" ]]; then + echo "Set OPENSYMPHONY_RELEASE_CONFIG to the selected central config file." >&2 + exit 1 +fi + +if [[ "$(duckdb --version 2>/dev/null || true)" != v1.5.3* ]]; then + echo "DuckDB 1.5.3 is required for the system-linked release gate." >&2 + exit 1 +fi + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +CANDIDATE_COMMIT="$(git rev-parse HEAD)" +CANDIDATE_CONFIG_SHA="$(sha256_file "${CONFIG_PATH}")" + +run() { + printf '\n[%s] %s\n' "$(date -u +%FT%TZ)" "$*" | tee -a "${LOG_FILE}" + "$@" 2>&1 | tee -a "${LOG_FILE}" +} + +export OPENSYMPHONY_RELEASE_CONFIG="${CONFIG_PATH}" +run cargo test-system-duckdb --lib release_candidate_selected_central_config_validates + +# H01-H03: strict configuration, legacy migration/rollback, and exclusive +# process ownership. These are inherited matrices, run intact by prefix. +run cargo test-system-duckdb --lib central_config_ +run cargo test-system-duckdb --lib migration_ +run cargo test-system-duckdb --lib apply_and_rollback_restore_legacy_files +run cargo test-system-duckdb --lib runtime_root_ownership_ +run cargo test-system-duckdb --lib strict_run_marker_ +run cargo test-system-duckdb --lib orchestrator_run::backends::tests + +# H04-H06: typed routing, three local bare repositories, contradictory +# instructions, retained generations, parent worktrees, and restart admission. +run cargo test-system-duckdb --test workspace_manager +run cargo test-system-duckdb --test issue_session_runner +run cargo test-system-duckdb --test client_resilience +run cargo test-system-duckdb --test codex_app_server + +# H07-H10: one affected-repository repair, provider reconciliation, current-head +# review policy, final refresh, capture, Git detachment, leases, and cleanup. +# The scheduler suite includes the three-repository repair-to-capture scenario. +run cargo test-system-duckdb --test scheduler +run cargo test-system-duckdb --test linear_client +run cargo test-system-duckdb --test run + +# H11: persisted memory, exact live-overlay authorization, stable grants, and +# isolated catalog coordination. +run cargo test-system-duckdb --lib central_memory_writers_share_a_catalog_coordination_lock +run cargo test-system-duckdb --lib memory_server_writer_gate_keeps_filesystem_lock_until_guards_drain +run cargo test-system-duckdb --lib memory_server_health_reports_pinned_config_generation +run cargo test-system-duckdb --lib strict_memory_context_requires_a_worker_scope_grant +run cargo test-system-duckdb --lib worker_memory_grant_ +run cargo test-system-duckdb --lib parent_overlay_requires_the_verified_target_to_remain_in_head_ancestry +run cargo test-system-duckdb --lib context_records_keep_the_base_commit_for_persisted_overlay_paths +run cargo test-system-duckdb --test memory + +# H12: operator projections agree across Rust and TypeScript clients. +run cargo test-system-duckdb --test snapshot_serialization operator_projection_round_trips_without_secret_or_path_fields +run cargo test-system-duckdb --test gateway_schema run_detail_roundtrips +run cargo test-system-duckdb --test gateway gateway_serves_run_detail +run cargo test-system-duckdb --test reducer +run cargo test-system-duckdb --test tui +run npm ci +run npm run type-check +run npx jest packages/gateway-schema/__tests__/fixtures.test.ts packages/api-client/__tests__/transport-contract.test.ts --runInBand +run npm run build --workspace=@opensymphony/web +run npm run build --workspace=@opensymphony/desktop + +# Release packaging uses the bundled DuckDB feature set, not the development alias. +run cargo clippy --all-targets -- -D warnings +run cargo test --workspace + +if [[ -n "$(git status --porcelain)" || "$(git rev-parse HEAD)" != "${CANDIDATE_COMMIT}" || "$(sha256_file "${CONFIG_PATH}")" != "${CANDIDATE_CONFIG_SHA}" ]]; then + echo "Release candidate changed while the hermetic gate was running." >&2 + exit 1 +fi +COMMIT_SHA="${CANDIDATE_COMMIT}" +CONFIG_SHA="${CANDIDATE_CONFIG_SHA}" +CONFIG_PATH="$(cd -- "$(dirname -- "${CONFIG_PATH}")" && pwd)/$(basename -- "${CONFIG_PATH}")" +jq -n \ + --arg run_id "${RUN_ID}" \ + --arg commit_sha "${COMMIT_SHA}" \ + --arg config_path "${CONFIG_PATH}" \ + --arg config_sha256 "${CONFIG_SHA}" \ + --arg completed_at "$(date -u +%FT%TZ)" \ + --arg log "${LOG_FILE}" \ + '{schema_version:1,run_id:$run_id,commit_sha:$commit_sha,config_path:$config_path,config_sha256:$config_sha256,project_set:"hermetic-local",production_activation:false,result:"passed",completed_at:$completed_at,log:$log}' \ + > "${RUN_DIR}/release-evidence.json" + +echo "Hermetic multi-repository lifecycle gate passed." +echo "Evidence: ${RUN_DIR}/release-evidence.json" diff --git a/scripts/live_multi_repo_rollout.sh b/scripts/live_multi_repo_rollout.sh new file mode 100755 index 000000000..b5e5de502 --- /dev/null +++ b/scripts/live_multi_repo_rollout.sh @@ -0,0 +1,1118 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")"/.. && pwd)" +LINEAR_HELPER="${ROOT_DIR}/.agents/skills/linear/scripts/linear_graphql.py" +LINEAR_QUERIES="${ROOT_DIR}/.agents/skills/linear/queries" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +SLUG="osym-live-$(python3 -c 'import uuid; print(uuid.uuid4().hex)')" +OUTPUT_ROOT="${OPENSYMPHONY_LIVE_OUTPUT_ROOT:-${ROOT_DIR}/target/live-multi-repo}" +RUN_DIR="${OUTPUT_ROOT%/}/${RUN_ID}" +RESOURCE_DIR="${RUN_DIR}/resources" +LOG_DIR="${RUN_DIR}/logs" +CONFIG_PATH="${RUN_DIR}/config.yaml" +MANIFEST_PATH="${RUN_DIR}/resources.json" +TEARDOWN_PATH="${RUN_DIR}/teardown.json" +MAX_SECONDS="${OPENSYMPHONY_LIVE_MAX_SECONDS:-3600}" +CLEANUP_MAX_SECONDS="${OPENSYMPHONY_LIVE_CLEANUP_MAX_SECONDS:-300}" +POLL_SECONDS="${OPENSYMPHONY_LIVE_POLL_SECONDS:-10}" + +REPOSITORIES=() +REPOSITORY_IDS=() +ISSUE_IDS=() +ISSUE_IDENTIFIERS=() +LABEL_IDS=() +PROJECT_ID="" +PROJECT_SLUG="" +ORCHESTRATOR_PID="" +WATCHDOG_PID="" +HERMETIC_SUPERVISOR_PID="" +PORT_RESERVER_PID="" +SCENARIO_PASSED=0 + +required_env=( + GH_TOKEN + LINEAR_API_KEY + OPENSYMPHONY_LIVE_GITHUB_OWNER + OPENSYMPHONY_LIVE_LINEAR_TEAM_ID + OPENSYMPHONY_LIVE_MODEL +) + +if [[ "${OPENSYMPHONY_LIVE_MULTI_REPO:-0}" != "1" ]]; then + echo "Set OPENSYMPHONY_LIVE_MULTI_REPO=1 to run the disposable live rollout." >&2 + exit 1 +fi + +for variable in "${required_env[@]}"; do + if [[ -z "${!variable:-}" ]]; then + echo "Missing required environment variable: ${variable}" >&2 + exit 1 + fi +done + +if [[ ! "${MAX_SECONDS}" =~ ^[1-9][0-9]*$ || ! "${CLEANUP_MAX_SECONDS}" =~ ^[1-9][0-9]*$ || ! "${POLL_SECONDS}" =~ ^[1-9][0-9]*$ ]]; then + echo "Live, cleanup, and poll intervals must be positive integer seconds." >&2 + exit 1 +fi + +for command in cargo codex git gh jq lsof python3 shasum; do + if ! command -v "${command}" >/dev/null 2>&1; then + echo "Missing required command: ${command}" >&2 + exit 1 + fi +done + +if [[ -n "$(git -C "${ROOT_DIR}" status --porcelain)" ]]; then + echo "The live rollout requires a clean immutable candidate checkout." >&2 + exit 1 +fi + +if ! codex login status 2>&1 | grep -Eq 'Logged in (using|with) ChatGPT'; then + echo "The Codex app-server harness requires an active ChatGPT login." >&2 + exit 1 +fi + +mkdir -p "${RESOURCE_DIR}" "${LOG_DIR}" +cd "${ROOT_DIR}" +write_intent() { + jq -n --arg run_id "${RUN_ID}" --arg slug "${SLUG}" \ + --arg owner "${OPENSYMPHONY_LIVE_GITHUB_OWNER}" \ + --arg team "${OPENSYMPHONY_LIVE_LINEAR_TEAM_ID}" \ + '{schema_version:1,run_id:$run_id,slug:$slug,github_owner:$owner,linear_team_id:$team, + repositories:(["alpha","beta","gamma"] | map($owner + "/" + $slug + "-" + .)), + project_name:$slug, + label_names:(["alpha","beta","gamma"] | map("repo:" + $slug + "-" + .)), + issue_titles:(["parent","alpha","beta","gamma"] | map($slug + "-" + .))}' \ + > "${RUN_DIR}/intent.json" +} +write_intent +export GIT_CONFIG_GLOBAL=/dev/null +cat > "${RESOURCE_DIR}/git-askpass.sh" <<'EOF' +#!/usr/bin/env bash +case "$1" in + *Username*) printf '%s' 'x-access-token' ;; + *Password*) printf '%s' "${GH_TOKEN}" ;; + *) exit 1 ;; +esac +EOF +chmod 700 "${RESOURCE_DIR}/git-askpass.sh" +export GIT_ASKPASS="${RESOURCE_DIR}/git-askpass.sh" +export GIT_TERMINAL_PROMPT=0 + +START_SECONDS="${SECONDS}" +DEADLINE_ACTIVE=1 +deadline_command() { + local remaining + if (( DEADLINE_ACTIVE )); then + remaining=$((MAX_SECONDS - (SECONDS - START_SECONDS))) + else + remaining=$((CLEANUP_MAX_SECONDS - (SECONDS - CLEANUP_START_SECONDS))) + fi + if (( remaining <= 0 )); then + echo "Live rollout deadline expired." >&2 + return 124 + fi + python3 - "${remaining}" "$@" <<'PY' +import os +import signal +import subprocess +import sys + +def interrupted(signum, _frame): + raise SystemExit(128 + signum) + +signal.signal(signal.SIGINT, interrupted) +signal.signal(signal.SIGTERM, interrupted) +child = subprocess.Popen(sys.argv[2:], start_new_session=True) +try: + sys.exit(child.wait(timeout=int(sys.argv[1]))) +except subprocess.TimeoutExpired: + print("Live rollout deadline expired during external command.", file=sys.stderr) + sys.exit(124) +finally: + if child.poll() is None: + try: + os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + child.wait(timeout=10) + except subprocess.TimeoutExpired: + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + child.wait() +PY +} + +gh() { + deadline_command gh "$@" +} + +git() { + deadline_command git "$@" +} + +linear() { + local query_file="$1" + local variables_file="$2" + deadline_command python3 "${LINEAR_HELPER}" \ + --query-file "${LINEAR_QUERIES}/${query_file}" \ + --variables-file "${variables_file}" +} + +write_json() { + local path="$1" + shift + jq -n "$@" > "${path}" +} + +decode_base64() { + python3 -c 'import base64, sys; sys.stdout.write(base64.b64decode(sys.stdin.read()).decode())' +} + +record_manifest() { + jq -n \ + --arg run_id "${RUN_ID}" \ + --arg project_id "${PROJECT_ID}" \ + --arg project_slug "${PROJECT_SLUG}" \ + --argjson repositories "$(printf '%s\n' "${REPOSITORIES[@]:-}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \ + --argjson repository_ids "$(printf '%s\n' "${REPOSITORY_IDS[@]:-}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \ + --argjson issue_ids "$(printf '%s\n' "${ISSUE_IDS[@]:-}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \ + --argjson label_ids "$(printf '%s\n' "${LABEL_IDS[@]:-}" | jq -Rsc 'split("\n") | map(select(length > 0))')" \ + '{schema_version:1,run_id:$run_id,project:{id:$project_id,slug:$project_slug},repositories:$repositories,repository_ids:$repository_ids,issue_ids:$issue_ids,label_ids:$label_ids}' \ + > "${MANIFEST_PATH}" +} + +delete_linear_resource() { + local query="$1" + local id="$2" + local vars="${RUN_DIR}/delete-${query}-${id}.json" + write_json "${vars}" --arg id "${id}" '{id:$id}' + local result + result="$(linear "${query}.graphql" "${vars}")" || return 1 + jq -e '.data | [.[] | .success] == [true]' <<<"${result}" >/dev/null +} + +repository_is_absent() { + local response + response="$(gh api -i "repos/$1" 2>&1)" + grep -Eq '^HTTP/[0-9.]+ 404 ' <<<"${response}" +} + +reconcile_linear_resources() { + local result id alias vars + vars="${RUN_DIR}/reconcile-project.json" + write_json "${vars}" --arg name "${SLUG}" '{name:$name}' + result="$(linear project_by_name.graphql "${vars}")" || return 1 + id="$(jq -r '.data.projects.nodes[0].id // empty' <<<"${result}")" + if [[ -n "${id}" ]]; then PROJECT_ID="${id}"; fi + for alias in alpha beta gamma; do + vars="${RUN_DIR}/reconcile-label-${alias}.json" + write_json "${vars}" --arg name "repo:${SLUG}-${alias}" --arg team "${OPENSYMPHONY_LIVE_LINEAR_TEAM_ID}" '{name:$name,teamId:$team,first:2}' + result="$(linear issue_label_by_name.graphql "${vars}")" || return 1 + id="$(jq -r '.data.issueLabels.nodes[0].id // empty' <<<"${result}")" + if [[ -n "${id}" && ! " ${LABEL_IDS[*]:-} " =~ " ${id} " ]]; then LABEL_IDS+=("${id}"); fi + done + for alias in parent alpha beta gamma; do + vars="${RUN_DIR}/reconcile-issue-${alias}.json" + write_json "${vars}" --arg title "${SLUG}-${alias}" --arg team "${OPENSYMPHONY_LIVE_LINEAR_TEAM_ID}" '{title:$title,teamId:$team}' + result="$(linear issue_by_title.graphql "${vars}")" || return 1 + id="$(jq -r '.data.issues.nodes[0].id // empty' <<<"${result}")" + if [[ -n "${id}" && ! " ${ISSUE_IDS[*]:-} " =~ " ${id} " ]]; then ISSUE_IDS+=("${id}"); fi + done + record_manifest +} + +orchestrator_group_alive() { + [[ -n "${ORCHESTRATOR_PID}" ]] && kill -0 -- "-${ORCHESTRATOR_PID}" 2>/dev/null +} + +cleanup() { + local cleanup_failed=0 + set +e + DEADLINE_ACTIVE=0 + CLEANUP_START_SECONDS="${SECONDS}" + if [[ -n "${HERMETIC_SUPERVISOR_PID}" ]] && kill -0 "${HERMETIC_SUPERVISOR_PID}" 2>/dev/null; then + kill -TERM "${HERMETIC_SUPERVISOR_PID}" 2>/dev/null + wait "${HERMETIC_SUPERVISOR_PID}" 2>/dev/null + fi + if [[ -n "${PORT_RESERVER_PID}" ]] && kill -0 "${PORT_RESERVER_PID}" 2>/dev/null; then + kill -TERM "${PORT_RESERVER_PID}" 2>/dev/null + wait "${PORT_RESERVER_PID}" 2>/dev/null + fi + if [[ -n "${WATCHDOG_PID}" ]] && kill -0 "${WATCHDOG_PID}" 2>/dev/null; then + kill "${WATCHDOG_PID}" 2>/dev/null + wait "${WATCHDOG_PID}" 2>/dev/null + fi + if orchestrator_group_alive; then + kill -TERM -- "-${ORCHESTRATOR_PID}" 2>/dev/null + for _ in $(seq 1 20); do + orchestrator_group_alive || break + sleep 1 + done + if orchestrator_group_alive; then kill -KILL -- "-${ORCHESTRATOR_PID}" 2>/dev/null; fi + fi + if [[ -n "${ORCHESTRATOR_PID}" ]]; then wait "${ORCHESTRATOR_PID}" 2>/dev/null; fi + + # Reconcile deterministic names before deleting: a create can succeed remotely + # even when its response never reaches this process. + reconcile_linear_resources || cleanup_failed=1 + for ((index=${#ISSUE_IDS[@]}-1; index>=0; index--)); do + delete_linear_resource issue_delete "${ISSUE_IDS[index]}" || cleanup_failed=1 + done + for ((index=${#LABEL_IDS[@]}-1; index>=0; index--)); do + delete_linear_resource issue_label_delete "${LABEL_IDS[index]}" || cleanup_failed=1 + done + if [[ -n "${PROJECT_ID}" ]]; then + delete_linear_resource project_delete "${PROJECT_ID}" || cleanup_failed=1 + fi + for ((index=${#REPOSITORIES[@]}-1; index>=0; index--)); do + gh repo delete "${REPOSITORIES[index]}" --yes >/dev/null 2>&1 || { + repository_is_absent "${REPOSITORIES[index]}" || cleanup_failed=1 + } + done + for repository in "${REPOSITORIES[@]:-}"; do + [[ -z "${repository}" ]] && continue + repository_is_absent "${repository}" || cleanup_failed=1 + done + + local remaining_local_roots=0 + local local_root + for local_root in "${RESOURCE_DIR}/seeds" "${RUN_DIR}/state" "${RUN_DIR}/workspaces"; do + rm -rf "${local_root}" || cleanup_failed=1 + if [[ -e "${local_root}" || -L "${local_root}" ]]; then + remaining_local_roots=$((remaining_local_roots + 1)) + cleanup_failed=1 + fi + done + local remaining_processes=0 + if orchestrator_group_alive; then + remaining_processes=1 + cleanup_failed=1 + fi + local remaining_port_owner=0 + if [[ -n "${PORT:-}" ]] && lsof -tiTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then + remaining_port_owner=1 + cleanup_failed=1 + fi + jq -n \ + --arg run_id "${RUN_ID}" \ + --argjson scenario_passed "${SCENARIO_PASSED}" \ + --argjson cleanup_failed "${cleanup_failed}" \ + --argjson remaining_processes "${remaining_processes}" \ + --argjson remaining_port_owner "${remaining_port_owner}" \ + --argjson remaining_local_roots "${remaining_local_roots}" \ + --arg completed_at "$(date -u +%FT%TZ)" \ + '{schema_version:1,run_id:$run_id,scenario_passed:($scenario_passed == 1),cleanup_complete:($cleanup_failed == 0),remaining_processes:$remaining_processes,remaining_port_owner:$remaining_port_owner,remaining_local_roots:$remaining_local_roots,credential_copies:0,completed_at:$completed_at}' \ + > "${TEARDOWN_PATH}" + set -e + if (( cleanup_failed != 0 )); then + echo "Live rollout teardown was incomplete: ${TEARDOWN_PATH}" >&2 + return 1 + fi + if (( SCENARIO_PASSED == 1 )); then + if ! jq --arg completed_at "$(date -u +%FT%TZ)" \ + '.result="passed" | .completed_at=$completed_at' \ + "${RUN_DIR}/release-evidence.json" > "${RUN_DIR}/release-evidence.tmp" || + ! mv "${RUN_DIR}/release-evidence.tmp" "${RUN_DIR}/release-evidence.json"; then + echo "Could not publish passing release evidence after teardown." >&2 + return 1 + fi + echo "Disposable live rollout and teardown passed." + echo "Evidence: ${RUN_DIR}/release-evidence.json" + fi +} +trap 'status=$?; trap - EXIT; cleanup || status=1; exit "${status}"' EXIT +trap 'exit 143' TERM +trap 'exit 130' INT + +state_vars="${RUN_DIR}/team-states.json" +write_json "${state_vars}" --arg id "${OPENSYMPHONY_LIVE_LINEAR_TEAM_ID}" '{id:$id}' +state_result="$(linear team_states.graphql "${state_vars}")" +state_id() { + local name="$1" + jq -er --arg name "${name}" '.data.team.states.nodes[] | select(.name == $name) | .id' <<<"${state_result}" +} +TODO_STATE="$(state_id Todo)" +HUMAN_REVIEW_STATE="$(state_id 'Human Review')" +REWORK_STATE="$(state_id Rework)" +DONE_STATE="$(state_id Done)" + +PORT_FILE="${RUN_DIR}/reserved-port" +python3 - "${PORT_FILE}" <<'PY' & +import os +from pathlib import Path +import signal +import socket +import sys + +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port_file = Path(sys.argv[1]) + temporary = port_file.with_suffix(".tmp") + temporary.write_text(str(sock.getsockname()[1])) + os.replace(temporary, port_file) + signal.pause() +PY +PORT_RESERVER_PID=$! +for _ in $(seq 1 50); do + [[ -s "${PORT_FILE}" ]] && break + kill -0 "${PORT_RESERVER_PID}" 2>/dev/null || break + sleep 0.1 +done +if [[ ! -s "${PORT_FILE}" ]]; then + echo "Could not reserve a control-plane port." >&2 + exit 1 +fi +PORT="$(cat "${PORT_FILE}")" +create_repository() { + local alias="$1" + local repository="${OPENSYMPHONY_LIVE_GITHUB_OWNER}/${SLUG}-${alias}" + local seed="${RESOURCE_DIR}/seeds/${alias}" + REPOSITORIES+=("${repository}") + record_manifest + gh repo create "${repository}" --private --disable-issues --disable-wiki >/dev/null + REPOSITORY_IDS+=("$(gh api "repos/${repository}" --jq '.id | tostring')") + mkdir -p "${seed}/scripts" "${seed}/.github/workflows" + git -C "${seed}" init -b develop >/dev/null + cat > "${seed}/AGENTS.md" <> "${seed}/AGENTS.md" <<'EOF' +Leave reviewed.txt absent on the first turn. Create it only after the PR check +fails and the issue requests a repair. +EOF + fi + printf 'component=%s\n' "${alias}" > "${seed}/component.txt" + if [[ "${alias}" == "alpha" ]]; then + printf 'answer=41\n' > "${seed}/answer.txt" + else + printf 'answer=42\n' > "${seed}/answer.txt" + fi + cat > "${seed}/scripts/check.sh" < "${seed}/scripts/check-integration.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +test "$(cat answer.txt)" = 'answer=42' +EOF + if [[ "${alias}" == "alpha" ]]; then + cat >> "${seed}/scripts/check.sh" </dev/null)" = "reviewed:${RUN_ID}" || { + echo "Create reviewed.txt containing reviewed:${RUN_ID}, then push the same PR branch." >&2 + exit 1 + } +fi +EOF + fi + chmod +x "${seed}/scripts/check.sh" "${seed}/scripts/check-integration.sh" + cat > "${seed}/.github/workflows/check.yml" <<'EOF' +name: fixture-check +on: + pull_request: + branches: [develop] +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - run: ./scripts/check.sh +EOF + git -C "${seed}" add . + git -C "${seed}" -c user.name='OpenSymphony Live Gate' -c user.email='live-gate@invalid.example' commit -m 'Initialize disposable fixture' >/dev/null + git -C "${seed}" remote add origin "https://github.com/${repository}.git" + git -C "${seed}" push -u origin develop >/dev/null + gh api -X PATCH "repos/${repository}" -f default_branch=develop >/dev/null +} + +for alias in alpha beta gamma; do + create_repository "${alias}" +done +record_manifest + +project_vars="${RUN_DIR}/project-create.json" +write_json "${project_vars}" \ + --arg name "${SLUG}" \ + --arg team "${OPENSYMPHONY_LIVE_LINEAR_TEAM_ID}" \ + '{input:{name:$name,teamIds:[$team],description:"Disposable OpenSymphony multi-repository rollout fixture"}}' +project_result="$(linear project_create.graphql "${project_vars}")" +PROJECT_ID="$(jq -er '.data.projectCreate.project.id' <<<"${project_result}")" +PROJECT_SLUG="$(jq -er '.data.projectCreate.project.slugId' <<<"${project_result}")" +record_manifest + +create_label() { + local alias="$1" + local vars="${RUN_DIR}/label-${alias}.json" + write_json "${vars}" \ + --arg name "repo:${alias}" \ + --arg team "${OPENSYMPHONY_LIVE_LINEAR_TEAM_ID}" \ + '{input:{name:$name,teamId:$team,color:"#5E6AD2",description:"Disposable repository binding"}}' + local result + result="$(linear issue_label_create.graphql "${vars}")" + LABEL_IDS+=("$(jq -er '.data.issueLabelCreate.issueLabel.id' <<<"${result}")") +} +for alias in alpha beta gamma; do + create_label "${SLUG}-${alias}" +done +record_manifest + +create_issue() { + local alias="$1" + local label_id="$2" + local parent_id="${3:-}" + local vars="${RUN_DIR}/issue-${alias}.json" + local title="${SLUG}-${alias}" + local description="Run the bounded disposable task described by this repository's AGENTS.md. Do not touch another repository." + if [[ "${alias}" == "parent" ]]; then + title="${SLUG}-parent" + description="Integrate all three terminal children. Observe alpha's seeded integration failure and request its repair without editing the initial checkout. On the active repair continuation, fix only alpha and leave beta and gamma unchanged." + fi + jq -n \ + --arg team "${OPENSYMPHONY_LIVE_LINEAR_TEAM_ID}" \ + --arg title "${title}" \ + --arg description "${description}" \ + --arg project "${PROJECT_ID}" \ + --arg parent "${parent_id}" \ + --arg label "${label_id}" \ + '{input:{teamId:$team,title:$title,description:$description,projectId:$project} + (if $parent == "" then {} else {parentId:$parent} end) + (if $label == "" then {} else {labelIds:[$label]} end)}' \ + > "${vars}" + local result + result="$(linear issue_create.graphql "${vars}")" + jq -cer '.data.issueCreate.issue | {id,identifier}' <<<"${result}" +} + +parent_result="$(create_issue parent "")" +PARENT_ID="$(jq -er .id <<<"${parent_result}")" +PARENT_IDENTIFIER="$(jq -er .identifier <<<"${parent_result}")" +ISSUE_IDS+=("${PARENT_ID}") +ISSUE_IDENTIFIERS+=("${PARENT_IDENTIFIER}") +CHILD_IDS=() +CHILD_IDENTIFIERS=() +for index in 0 1 2; do + alias=(alpha beta gamma) + child_result="$(create_issue "${alias[index]}" "${LABEL_IDS[index]}" "${PARENT_ID}")" + child_id="$(jq -er .id <<<"${child_result}")" + child_identifier="$(jq -er .identifier <<<"${child_result}")" + ISSUE_IDS+=("${child_id}") + ISSUE_IDENTIFIERS+=("${child_identifier}") + CHILD_IDS+=("${child_id}") + CHILD_IDENTIFIERS+=("${child_identifier}") +done +record_manifest + +# The controller moves a successful leaf out of the active tracker set only +# after its completed turn is durable, before the next scheduler retry tick. +for index in 0 1 2; do + alias=(alpha beta gamma) + seed="${RESOURCE_DIR}/seeds/${alias[index]}" + cat > "${seed}/scripts/complete.sh" </dev/null + git -C "${seed}" push origin develop >/dev/null +done + +move_issue() { + local issue_id="$1" + local target_state="$2" + local vars="${RUN_DIR}/move-${issue_id}.json" + write_json "${vars}" --arg id "${issue_id}" --arg state "${target_state}" '{id:$id,stateId:$state}' + linear issue_move_to_state.graphql "${vars}" | jq -e '.data.issueUpdate.success == true' >/dev/null +} +update_issue_title() { + local issue_id="$1" + local title="$2" + local vars="${RUN_DIR}/title-${issue_id}.json" + write_json "${vars}" --arg id "${issue_id}" --arg title "${title}" '{id:$id,input:{title:$title}}' + linear issue_update.graphql "${vars}" | jq -e '.data.issueUpdate.success == true' >/dev/null +} +for issue_id in "${ISSUE_IDS[@]}"; do + move_issue "${issue_id}" "${TODO_STATE}" +done + +cat > "${RUN_DIR}/integration.md" < "${CONFIG_PATH}" <> "${CONFIG_PATH}" <> "${CONFIG_PATH}" <&2 + exit 1 +fi +OPENSYMPHONY_RELEASE_CONFIG="${CONFIG_PATH}" \ +OPENSYMPHONY_HERMETIC_OUTPUT_ROOT="${RUN_DIR}/hermetic" \ + python3 - "${remaining_seconds}" "${ROOT_DIR}/scripts/hermetic_multi_repo_lifecycle.sh" "${LOG_DIR}/hermetic.log" <<'PY' & +import os +import signal +import subprocess +import sys + +def interrupted(signum, _frame): + raise SystemExit(128 + signum) + +signal.signal(signal.SIGINT, interrupted) +signal.signal(signal.SIGTERM, interrupted) +with open(sys.argv[3], "wb") as log: + gate = subprocess.Popen([sys.argv[2]], stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + try: + result = gate.wait(timeout=int(sys.argv[1])) + except subprocess.TimeoutExpired: + print("Hermetic gate exceeded the overall live rollout deadline.", file=sys.stderr) + sys.exit(124) + finally: + if gate.poll() is None: + try: + os.killpg(gate.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + gate.wait(timeout=10) + except subprocess.TimeoutExpired: + try: + os.killpg(gate.pid, signal.SIGKILL) + except ProcessLookupError: + pass + gate.wait() + sys.exit(result) +PY +HERMETIC_SUPERVISOR_PID=$! +wait "${HERMETIC_SUPERVISOR_PID}" +HERMETIC_SUPERVISOR_PID="" + +COMMIT_SHA="$(git rev-parse HEAD)" +CONFIG_SHA="$(shasum -a 256 "${CONFIG_PATH}" | awk '{print $1}')" +HERMETIC_EVIDENCE="$(find "${RUN_DIR}/hermetic" -name release-evidence.json -print -quit)" +jq -e --arg commit "${COMMIT_SHA}" --arg config "${CONFIG_SHA}" \ + '.result == "passed" and .commit_sha == $commit and .config_sha256 == $config' \ + "${HERMETIC_EVIDENCE}" >/dev/null +jq -n \ + --arg run_id "${RUN_ID}" \ + --arg commit_sha "${COMMIT_SHA}" \ + --arg config_sha256 "${CONFIG_SHA}" \ + --arg hermetic_evidence "${HERMETIC_EVIDENCE}" \ + --arg project_id "${PROJECT_ID}" \ + --arg project_slug "${PROJECT_SLUG}" \ + --arg port "${PORT}" \ + '{schema_version:1,run_id:$run_id,commit_sha:$commit_sha,config_sha256:$config_sha256,hermetic_evidence:$hermetic_evidence,project_set:"live",linear_project:{id:$project_id,slug:$project_slug},control_plane_port:($port|tonumber),production_activation:false,result:"started"}' \ + > "${RUN_DIR}/release-evidence.json" + +if ! kill -0 "${PORT_RESERVER_PID}" 2>/dev/null; then + echo "The reserved control-plane port was lost before launch." >&2 + exit 1 +fi +kill -TERM "${PORT_RESERVER_PID}" +wait "${PORT_RESERVER_PID}" +PORT_RESERVER_PID="" +remaining_seconds=$((MAX_SECONDS - (SECONDS - START_SECONDS))) +if (( remaining_seconds <= 0 )); then + echo "Live rollout deadline expired before orchestrator start." >&2 + exit 1 +fi +python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' \ + cargo run -- run --config "${CONFIG_PATH}" >"${LOG_DIR}/orchestrator.log" 2>&1 & +ORCHESTRATOR_PID=$! +for _ in $(seq 1 50); do + orchestrator_group_alive && break + kill -0 "${ORCHESTRATOR_PID}" 2>/dev/null || break + sleep 0.1 +done +if ! orchestrator_group_alive; then + echo "The orchestrator process group did not start." >&2 + exit 1 +fi +python3 - "${remaining_seconds}" "${ORCHESTRATOR_PID}" "${LOG_DIR}/watchdog.log" <<'PY' & +import os +import signal +import sys +import time + +time.sleep(int(sys.argv[1])) +try: + os.killpg(int(sys.argv[2]), 0) +except ProcessLookupError: + sys.exit(0) +with open(sys.argv[3], "a") as log: + log.write("Live rollout exceeded its deadline.\n") +try: + os.killpg(int(sys.argv[2]), signal.SIGTERM) +except ProcessLookupError: + pass +PY +WATCHDOG_PID=$! + +attach_pr() { + local issue_id="$1" + local url="$2" + local title="$3" + local vars="${RUN_DIR}/attach-${issue_id}.json" + write_json "${vars}" --arg issue "${issue_id}" --arg url "${url}" --arg title "${title}" '{issueId:$issue,url:$url,title:$title}' + linear attachment_link_github_pr.graphql "${vars}" | jq -e '.data.attachmentLinkGitHubPR.success == true' >/dev/null +} + +pr_by_branch() { + local repository="$1" + local branch="$2" + gh api "repos/${repository}/pulls?state=all&head=${OPENSYMPHONY_LIVE_GITHUB_OWNER}:${branch}&base=develop&per_page=100" | + jq -cer --arg branch "${branch}" ' + ([.[] | select(.head.ref == $branch and .base.ref == "develop")][0] // empty) + | {number,url:.html_url,title,headRefOid:.head.sha, + state:(if .merged_at then "MERGED" elif .state == "open" then "OPEN" else "CLOSED" end)}' +} + +publish_child_if_ready() { + local index="$1" + local alias=(alpha beta gamma) + local repository="${REPOSITORIES[index]}" + local branch="feat/${CHILD_IDENTIFIERS[index]}-${SLUG}-${alias[index]}" + local checkout candidate publisher + checkout="" + for candidate in "${RUN_DIR}/workspaces/${CHILD_IDENTIFIERS[index]}-"*--*; do + [[ -d "${candidate}/.git" && ! -L "${candidate}" && ! -L "${candidate}/.git" ]] || continue + [[ -z "${checkout}" ]] || { echo "Multiple retained child checkouts for ${CHILD_IDENTIFIERS[index]}" >&2; return 1; } + checkout="${candidate}" + done + [[ -n "${checkout}" ]] || return 0 + if (( CHILD_REVIEW_TRANSITIONED[index] == 0 )); then + jq -e --arg id "${CHILD_IDS[index]}" \ + '.issue_id == $id and .status == "succeeded" and .harness_stopped == true' \ + "${checkout}/.opensymphony/run.json" >/dev/null 2>&1 || return 0 + if (( index == 0 && ALPHA_REWORK_REQUIRED == 1 )); then + [[ -n "${ALPHA_INITIAL_RECEIPT_RUN_ID}" ]] || return 1 + jq -e --arg prior "${ALPHA_INITIAL_RECEIPT_RUN_ID}" \ + '.run_id != $prior' "${checkout}/.opensymphony/run.json" >/dev/null || return 0 + fi + fi + [[ -f "${checkout}/delivery.txt" && ! -L "${checkout}/delivery.txt" ]] || return 0 + [[ "$(cat "${checkout}/delivery.txt")" == "delivered:${alias[index]}:${RUN_ID}" ]] || return 0 + if (( index == 0 && ALPHA_REWORK_REQUIRED == 1 )); then + [[ -f "${checkout}/reviewed.txt" && ! -L "${checkout}/reviewed.txt" ]] || return 0 + [[ "$(cat "${checkout}/reviewed.txt" 2>/dev/null || true)" == "reviewed:${RUN_ID}" ]] || return 0 + fi + if (( CHILD_REVIEW_TRANSITIONED[index] == 0 )); then + if (( index == 0 && ALPHA_REWORK_REQUIRED == 0 )); then + ALPHA_INITIAL_RECEIPT_RUN_ID="$(jq -er .run_id "${checkout}/.opensymphony/run.json")" + fi + move_issue "${CHILD_IDS[index]}" "${HUMAN_REVIEW_STATE}" + CHILD_REVIEW_TRANSITIONED[index]=1 + fi + if (( index != 0 || ALPHA_REWORK_REQUIRED == 0 )) && + pr_by_branch "${repository}" "${branch}" >/dev/null 2>&1; then + return 0 + fi + publisher="${RESOURCE_DIR}/seeds/publisher-${alias[index]}" + rm -rf "${publisher}" + git clone --quiet "https://github.com/${repository}.git" "${publisher}" + if git -C "${publisher}" ls-remote --exit-code --heads origin "${branch}" >/dev/null; then + git -C "${publisher}" fetch origin "refs/heads/${branch}" >/dev/null + git -C "${publisher}" checkout -B "${branch}" FETCH_HEAD >/dev/null + else + git -C "${publisher}" checkout -b "${branch}" origin/develop >/dev/null + fi + rm -f "${publisher}/delivery.txt" "${publisher}/reviewed.txt" + cp "${checkout}/delivery.txt" "${publisher}/delivery.txt" + # Keep the first alpha PR red even if its worker anticipated the repair marker. + if (( index == 0 && ALPHA_REWORK_REQUIRED == 1 )); then + cp "${checkout}/reviewed.txt" "${publisher}/reviewed.txt" + fi + git -C "${publisher}" add -- delivery.txt + if [[ -f "${publisher}/reviewed.txt" ]]; then git -C "${publisher}" add -- reviewed.txt; fi + if ! git -C "${publisher}" diff --cached --quiet; then + git -C "${publisher}" -c core.hooksPath=/dev/null \ + -c user.name='OpenSymphony Live Gate' -c user.email='live-gate@invalid.example' \ + commit -m "Complete ${alias[index]} disposable task" >/dev/null + fi + git -C "${publisher}" -c core.hooksPath=/dev/null push \ + "https://github.com/${repository}.git" "HEAD:refs/heads/${branch}" >/dev/null + if ! pr_by_branch "${repository}" "${branch}" >/dev/null 2>&1; then + gh api -X POST "repos/${repository}/pulls" -f base=develop -f head="${branch}" \ + -f title="${SLUG}-${alias[index]} disposable delivery" \ + -f body="Disposable isolated lifecycle fixture for ${CHILD_IDENTIFIERS[index]}." >/dev/null || + pr_by_branch "${repository}" "${branch}" >/dev/null + fi +} + +checks_are_green() { + local repository="$1" + local number="$2" + local sha + sha="$(gh api "repos/${repository}/pulls/${number}" --jq .head.sha)" + gh api "repos/${repository}/commits/${sha}/check-runs?per_page=100" | jq -r ' + .total_count == (.check_runs | length) and (.check_runs | length) > 0 and all(.check_runs[]; + .status == "completed" and (.conclusion == "success" or .conclusion == "neutral" or .conclusion == "skipped"))' +} + +checks_have_failed() { + local repository="$1" + local number="$2" + local sha + sha="$(gh api "repos/${repository}/pulls/${number}" --jq .head.sha)" + gh api "repos/${repository}/commits/${sha}/check-runs?per_page=100" | jq -r ' + any(.check_runs[]; .conclusion == "failure" or .conclusion == "timed_out" or .conclusion == "action_required")' +} + +retire_published_child_edit() { + local index="$1" + local repository="$2" + local alias=(alpha beta gamma) + local checkout="" candidate count=0 expected_status + for candidate in "${RUN_DIR}/workspaces/${CHILD_IDENTIFIERS[index]}-"*--*; do + [[ -d "${candidate}" ]] || continue + checkout="${candidate}" + count=$((count + 1)) + done + [[ "${count}" == 1 ]] || return 1 + [[ -f "${checkout}/delivery.txt" && ! -L "${checkout}/delivery.txt" ]] || return 1 + [[ "$(cat "${checkout}/delivery.txt")" == "delivered:${alias[index]}:${RUN_ID}" ]] || return 1 + [[ "$(gh api "repos/${repository}/contents/delivery.txt?ref=develop" --jq .content | tr -d '\n' | decode_base64)" == "delivered:${alias[index]}:${RUN_ID}" ]] || return 1 + expected_status='?? delivery.txt' + if (( index == 0 )); then + [[ -f "${checkout}/reviewed.txt" && ! -L "${checkout}/reviewed.txt" ]] || return 1 + [[ "$(cat "${checkout}/reviewed.txt")" == "reviewed:${RUN_ID}" ]] || return 1 + [[ "$(gh api "repos/${repository}/contents/reviewed.txt?ref=develop" --jq .content | tr -d '\n' | decode_base64)" == "reviewed:${RUN_ID}" ]] || return 1 + expected_status+=$'\n?? reviewed.txt' + fi + [[ "$(git -C "${checkout}" status --porcelain --untracked-files=all)" == "${expected_status}" ]] || return 1 + rm -- "${checkout}/delivery.txt" + if (( index == 0 )); then rm -- "${checkout}/reviewed.txt"; fi + [[ -z "$(git -C "${checkout}" status --porcelain --untracked-files=all)" ]] +} + +declare -a CHILD_ATTACHED=(0 0 0) +declare -a CHILD_MERGED=(0 0 0) +declare -a CHILD_REVIEW_TRANSITIONED=(0 0 0) +ALPHA_REWORK_REQUIRED=0 +ALPHA_FAILED_SHA="" +ALPHA_INITIAL_RECEIPT_RUN_ID="" +PARENT_ATTACHED=0 +PARENT_DONE=0 +PARENT_MERGED=0 + +parent_controller_is_complete() { + local state_path="${RUN_DIR}/workspaces/.opensymphony-orchestrator-state.json" + [[ -f "${state_path}" ]] || return 1 + jq -e --arg parent_id "${PARENT_ID}" ' + .parent_integrations[$parent_id] as $controller + | $controller.state == "completed" + and $controller.final_evidence != null + and ($controller.repair_attempts | length) == 1 + and all($controller.repair_attempts[]; .status == "completed") + ' "${state_path}" >/dev/null +} + +parent_final_verification_passed() { + local state_path="${RUN_DIR}/workspaces/.opensymphony-orchestrator-state.json" + [[ -f "${state_path}" ]] || return 1 + jq -e --arg parent_id "${PARENT_ID}" ' + .parent_integrations[$parent_id] as $controller + | $controller.state == "integrating" + and $controller.attempts[-1].status == "passed" + and ($controller.repair_attempts | length) == 1 + and all($controller.repair_attempts[]; .status == "completed") + ' "${state_path}" >/dev/null +} + +managed_workspaces_remaining() { + local root="${RUN_DIR}/workspaces" + if [[ -L "${root}" ]]; then printf '%s\n' "${root}"; return; fi + if [[ -e "${root}" && ! -d "${root}" ]]; then printf '%s\n' "${root}"; return; fi + [[ -d "${root}" ]] || return 0 + find "${root}" -mindepth 1 -maxdepth 1 \ + ! -name '.opensymphony-orchestrator-state.json' \ + ! -name '.opensymphony-instance.lock' \ + ! \( -name '.opensymphony-staging' -type d \) \ + ! \( -name '.opensymphony-parent-pins' -type d \) \ + ! \( -name '.opensymphony-cleanup-tombstones' -type d \) \ + ! \( -name parents -type d \) -print || printf 'scan-error:%s\n' "${root}" + if [[ -L "${root}/.opensymphony-orchestrator-state.json" ]]; then + printf '%s\n' "${root}/.opensymphony-orchestrator-state.json" + fi + if [[ -d "${root}/.opensymphony-staging" && ! -L "${root}/.opensymphony-staging" ]]; then + find "${root}/.opensymphony-staging" -mindepth 1 -print || printf 'scan-error:%s\n' "${root}/.opensymphony-staging" + fi + if [[ -d "${root}/.opensymphony-parent-pins" && ! -L "${root}/.opensymphony-parent-pins" ]]; then + find "${root}/.opensymphony-parent-pins" -mindepth 1 -print || printf 'scan-error:%s\n' "${root}/.opensymphony-parent-pins" + fi + if [[ -d "${root}/parents" && ! -L "${root}/parents" ]]; then + find "${root}/parents" -mindepth 1 -maxdepth 1 ! -type d -print || printf 'scan-error:%s\n' "${root}/parents" + find "${root}/parents" -mindepth 2 -print || printf 'scan-error:%s\n' "${root}/parents" + fi +} + +while (( SECONDS - START_SECONDS < MAX_SECONDS )); do + if ! kill -0 "${ORCHESTRATOR_PID}" 2>/dev/null; then + echo "OpenSymphony exited before the live lifecycle completed." >&2 + wait "${ORCHESTRATOR_PID}" || true + exit 1 + fi + + for index in 0 1 2; do + (( CHILD_MERGED[index] == 0 )) || continue + publish_child_if_ready "${index}" + alias=(alpha beta gamma) + repository="${REPOSITORIES[index]}" + branch="feat/${CHILD_IDENTIFIERS[index]}-${SLUG}-${alias[index]}" + if ! pr="$(pr_by_branch "${repository}" "${branch}" 2>/dev/null)"; then + continue + fi + pr_number="$(jq -er .number <<<"${pr}")" + pr_url="$(jq -er .url <<<"${pr}")" + pr_title="$(jq -er .title <<<"${pr}")" + pr_sha="$(jq -er .headRefOid <<<"${pr}")" + if (( CHILD_ATTACHED[index] == 0 )); then + attach_pr "${CHILD_IDS[index]}" "${pr_url}" "${pr_title}" + CHILD_ATTACHED[index]=1 + fi + + if (( index == 0 && ALPHA_REWORK_REQUIRED == 0 )); then + if [[ "$(checks_have_failed "${repository}" "${pr_number}")" != "true" ]]; then + continue + fi + ALPHA_REWORK_REQUIRED=1 + ALPHA_FAILED_SHA="${pr_sha}" + update_issue_title "${CHILD_IDS[index]}" "${SLUG}-alpha: CI failed; create reviewed.txt containing reviewed:${RUN_ID}" + move_issue "${CHILD_IDS[index]}" "${REWORK_STATE}" + CHILD_REVIEW_TRANSITIONED[index]=0 + feedback_vars="${RUN_DIR}/alpha-rework-comment.json" + write_json "${feedback_vars}" --arg id "${CHILD_IDS[index]}" \ + --arg body "The fixture-check GitHub Actions job failed on this PR. Read its log, create reviewed.txt containing reviewed:${RUN_ID}, rerun ./scripts/check.sh, and leave the edit in this checkout for the rollout controller to publish to the same PR branch." \ + '{issueId:$id,body:$body}' + linear comment_create.graphql "${feedback_vars}" | jq -e '.data.commentCreate.success == true' >/dev/null + continue + fi + if (( index == 0 )) && [[ "${pr_sha}" == "${ALPHA_FAILED_SHA}" ]]; then + continue + fi + if (( index == 0 )); then + reviewed_value="$(gh api "repos/${repository}/contents/reviewed.txt?ref=${branch}" --jq .content 2>/dev/null | tr -d '\n' | decode_base64 || true)" + answer_value="$(gh api "repos/${repository}/contents/answer.txt?ref=${branch}" --jq .content 2>/dev/null | tr -d '\n' | decode_base64 || true)" + if [[ "${reviewed_value}" != "reviewed:${RUN_ID}" || "${answer_value}" != 'answer=41' ]]; then + continue + fi + fi + if [[ "$(checks_are_green "${repository}" "${pr_number}")" != "true" ]]; then + continue + fi + gh api -X PUT "repos/${repository}/pulls/${pr_number}/merge" -f merge_method=merge -f sha="${pr_sha}" | + jq -e '.merged == true' >/dev/null + gh api -X DELETE "repos/${repository}/git/refs/heads/${branch}" >/dev/null 2>&1 || true + retire_published_child_edit "${index}" "${repository}" + git -C "${RESOURCE_DIR}/seeds/${alias[index]}" fetch origin develop >/dev/null + git -C "${RESOURCE_DIR}/seeds/${alias[index]}" reset --hard FETCH_HEAD >/dev/null + move_issue "${CHILD_IDS[index]}" "${DONE_STATE}" + CHILD_MERGED[index]=1 + done + + if (( CHILD_MERGED[0] == 1 && CHILD_MERGED[1] == 1 && CHILD_MERGED[2] == 1 && PARENT_MERGED == 0 )); then + repository="${REPOSITORIES[0]}" + pr="$(gh api "repos/${repository}/pulls?state=all&base=develop&per_page=100" | jq -c ' + [.[] | select(.head.ref | startswith("fix/")) | + {number,url:.html_url,title,headRefName:.head.ref, + state:(if .merged_at then "MERGED" elif .state == "open" then "OPEN" else "CLOSED" end)}][0]')" + if [[ "${pr}" != "null" ]]; then + pr_number="$(jq -er .number <<<"${pr}")" + pr_state="$(jq -er .state <<<"${pr}")" + if (( PARENT_ATTACHED == 0 )); then + attach_pr "${PARENT_ID}" "$(jq -er .url <<<"${pr}")" "$(jq -er .title <<<"${pr}")" + PARENT_ATTACHED=1 + fi + if [[ "${pr_state}" == "MERGED" ]] && (( PARENT_DONE == 0 )) && parent_final_verification_passed; then + move_issue "${PARENT_ID}" "${DONE_STATE}" + PARENT_DONE=1 + fi + if [[ "${pr_state}" == "MERGED" ]] && parent_controller_is_complete; then + jq --arg parent_id "${PARENT_ID}" '.parent_integrations[$parent_id]' \ + "${RUN_DIR}/workspaces/.opensymphony-orchestrator-state.json" \ + > "${RUN_DIR}/parent-controller-complete.json" + PARENT_MERGED=1 + fi + fi + fi + + if (( PARENT_MERGED == 1 )); then + # Subtree cleanup follows the terminal refresh cadence. Allow two full + # five-minute windows while retaining the overall scenario deadline. + for _ in $(seq 1 600); do + if (( SECONDS - START_SECONDS >= MAX_SECONDS )); then break; fi + if [[ -z "$(managed_workspaces_remaining)" ]]; then + break + fi + sleep 1 + done + break + fi + sleep "${POLL_SECONDS}" +done + +if (( PARENT_MERGED != 1 || ALPHA_REWORK_REQUIRED != 1 )); then + echo "The bounded live lifecycle did not complete before its deadline." >&2 + exit 1 +fi + +if [[ -n "$(managed_workspaces_remaining)" ]]; then + echo "OpenSymphony did not complete eligible workspace cleanup." >&2 + exit 1 +fi + +if [[ "$(gh api "repos/${REPOSITORIES[0]}/contents/answer.txt?ref=develop" --jq .content | tr -d '\n' | decode_base64)" != 'answer=42' ]]; then + echo "The affected repository did not contain the repaired value on develop." >&2 + exit 1 +fi +for index in 1 2; do + if [[ "$(gh api "repos/${REPOSITORIES[index]}/contents/answer.txt?ref=develop" --jq .content | tr -d '\n' | decode_base64)" != 'answer=42' ]]; then + echo "An unaffected repository changed its expected integration value." >&2 + exit 1 + fi +done + +jq -n \ + --arg run_id "${RUN_ID}" \ + --arg parent "${PARENT_IDENTIFIER}" \ + --arg failed_check_sha "${ALPHA_FAILED_SHA}" \ + --argjson child_merges "$(printf '%s\n' "${CHILD_MERGED[@]}" | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)')" \ + '{schema_version:1,run_id:$run_id,result:"passed",parent:$parent,child_merges:$child_merges,failed_check_sha:$failed_check_sha,parent_repair_merge:true,cleanup_handoff:true}' \ + > "${RUN_DIR}/live-rollout-summary.json" + +if [[ -n "$(git status --porcelain)" || "$(git rev-parse HEAD)" != "${COMMIT_SHA}" || "$(shasum -a 256 "${CONFIG_PATH}" | awk '{print $1}')" != "${CONFIG_SHA}" ]]; then + echo "Candidate checkout or selected config changed during the live rollout." >&2 + exit 1 +fi + +SCENARIO_PASSED=1 From fbfea68013288066aaf7f93c9e5b803969c7a379 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 26 Sep 2026 13:46:58 -0500 Subject: [PATCH 25/29] chore: prepare OpenSymphony 3.0.0 docs and metadata --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 33 +++++++-- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- docs/acp.md | 89 +++++++++++++++++++++++- docs/architecture.md | 31 ++++++--- docs/configuration.md | 5 ++ docs/migration-3.0.0.md | 60 ++++++++++++++++ docs/multi-repository-rollout.md | 4 ++ docs/multi-repository.md | 94 ++++++++++++++++++++++++++ docs/operations.md | 5 ++ docs/release.md | 30 ++++++++ package-lock.json | 2 +- 16 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 docs/migration-3.0.0.md create mode 100644 docs/multi-repository.md diff --git a/Cargo.lock b/Cargo.lock index 33ba4022f..e6fa472ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2506,7 +2506,7 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "opensymphony" -version = "2.11.3" +version = "3.0.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", diff --git a/Cargo.toml b/Cargo.toml index 958b607a1..52a1d6b8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,7 @@ edition = "2024" license = "MIT" repository = "https://github.com/kumanday/OpenSymphony" rust-version = "1.97.1" -version = "2.11.3" +version = "3.0.0" [workspace.lints.rust] rust_2018_idioms = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index a9bbfd405..3ca2b06f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenSymphony -OpenSymphony is a Rust implementation of the [OpenAI Symphony](https://github.com/openai/symphony) specification for orchestrating AI coding agents. It connects to [Linear](https://linear.app) for issue tracking and can run issues through either the managed [OpenHands](https://github.com/OpenHands/OpenHands) agent-server or the local Codex app-server harness. +OpenSymphony is a Rust implementation of the [OpenAI Symphony](https://github.com/openai/symphony) specification for orchestrating AI coding agents. It connects to [Linear](https://linear.app) for issue tracking and runs issues through the managed [OpenHands](https://github.com/OpenHands/OpenHands) agent-server, the local Codex app-server, or a local ACP v1 agent. ![OpenSymphony desktop task graph showing Completed, Current, and Backlog panes with selected-task critical path](docs/images/os-tg-ancestry-critical-path.png) @@ -17,11 +17,12 @@ OpenSymphony automates software development workflows by: ### Key Features - **Hierarchy-aware scheduling**: Parent issues wait for sub-issues to complete +- **Multi-repository projects**: Each child names one repository; a repository-neutral parent checks the merged result - **WebSocket-first runtime**: Real-time agent updates with REST reconciliation - **Per-issue workspaces**: Deterministic, isolated directories with lifecycle hooks - **GraphQL-only Linear integration**: Agent-side Linear reads and writes through checked-in helper/query assets - **Dependency-aware Task Graph**: Shows dispatchable work, roadmap backlog, and selected-task critical paths -- **Harness selection**: Default OpenHands agent-server execution, plus local Codex app-server support for ChatGPT subscription-backed runs +- **Harness selection**: OpenHands, local Codex app-server, or a configured ACP v1 stdio agent - **Code Graph**: Tree-sitter-backed symbols, diagnostics, and source-cited structural context for agents - **Operational Knowledge Graph**: Builds agent-queriable and human-navigable memory from completed work @@ -29,6 +30,11 @@ OpenSymphony `1.0.0` is the compatibility boundary for the GraphQL-only Linear rewrite. See [Migration Guide](docs/migration-1.0.0.md) if you are upgrading an older setup. +Version 3.0.0 adds explicit multi-repository project routing and production +ACP worker profiles. Existing single-repository workflows remain available. +See the [3.0 upgrade guide](docs/migration-3.0.0.md) before selecting a new +routing mode or harness. + Packaging note: crates.io exposes a single public package, `opensymphony`. Internally, the repo still keeps clear subsystem boundaries under `crates/opensymphony-*`, but those directories are now internal module trees, @@ -216,6 +222,15 @@ cd /path/to/target-repo opensymphony run ``` +For a central configuration in another location, pass +`--config /absolute/path/to/config.yaml` to `opensymphony run`. +The default single-repository workflow needs no multi-repository labels. +For a project spanning several repositories, follow the +[multi-repository guide](docs/multi-repository.md). To run a local ACP agent, +follow the [ACP harness guide](docs/acp.md). Repository routing and harness +selection are independent: an ACP profile can run a child bound to one +repository in a multi-repository project. + For real-time monitoring while the orchestrator is running, run the TUI in a separate terminal window: ```bash opensymphony tui @@ -335,10 +350,11 @@ flowchart TB orchestrator["Orchestrator Scheduler"] workspace["Workspace Manager"] control["Gateway + Control API
GET /healthz, /api/v1/snapshot, /api/v1/capabilities"] - runtime["Harness Runtime Client
OpenHands REST/WebSocket or Codex stdio"] + runtime["Harness Runtime Client"] linear_read["Linear Read Adapter"] + routing["Repository binding and parent integration"] - orchestrator --> workspace + orchestrator --> routing --> workspace orchestrator --> runtime orchestrator --> linear_read orchestrator --> control @@ -357,13 +373,19 @@ flowchart TB linear["Linear"] openhands["OpenHands Agent-Server"] codex["Codex App-Server"] + acp["Local ACP v1 agent"] + config["Central project set and harness profiles"] operator --> control + config --> routing + config --> runtime workspace --> issue_ws runtime --> openhands runtime --> codex + runtime --> acp openhands --> agent codex --> agent + acp --> agent linear_read -->|read issues| linear graphql -->|agent-side writes| linear ``` @@ -517,11 +539,14 @@ OPENSYMPHONY_LIVE_OPENHANDS=1 ./scripts/live_e2e.sh - [Architecture](docs/architecture.md) - High-level design and component interactions - [Configuration](docs/configuration.md) - Target repo bootstrap and runtime config +- [Multi-repository projects](docs/multi-repository.md) - Repository labels, parent integration, and rollout +- [ACP harness](docs/acp.md) - Local agent profiles, operator requests, and recovery - [Deployment Modes](docs/deployment-modes.md) - Local vs hosted deployment - [Installer and Distribution Strategy](docs/installer-and-distribution.md) - Future signed installer shape and DuckDB packaging boundaries - [Operations](docs/operations.md) - Doctor, rehydration, diagnostics, and local ops - [Testing](docs/testing-and-operations.md) - Test strategy and validation layers - [Migration Guide](docs/migration-1.0.0.md) - Breaking changes and upgrade steps for 1.0.0 +- [3.0 upgrade guide](docs/migration-3.0.0.md) - Moving from single-repository workflows and selecting ACP - [AGENTS.md](AGENTS.md) - Repository guidelines for coding agents - [Development Guide](docs/DEVELOPMENT.md) - Contributing and development details diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c27a7aaf2..96de2095a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opensymphony/desktop", - "version": "2.11.3", + "version": "3.0.0", "description": "OpenSymphony Tauri desktop app shell", "type": "module", "private": true, diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index f62ccc9ff..a525103c5 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -2501,7 +2501,7 @@ dependencies = [ [[package]] name = "opensymphony-desktop" -version = "2.11.3" +version = "3.0.0" dependencies = [ "chrono", "directories", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 112985ebd..1b81df017 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opensymphony-desktop" -version = "2.11.3" +version = "3.0.0" description = "OpenSymphony Tauri desktop application shell" license = "MIT" homepage = "https://opensymphony.dev" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 2a3b03542..45cb9cf91 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "OpenSymphony", "mainBinaryName": "OpenSymphony", - "version": "2.11.3", + "version": "3.0.0", "identifier": "dev.opensymphony.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/docs/acp.md b/docs/acp.md index cb42eaeb8..70c24d987 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -5,7 +5,94 @@ visibility: public last_memory_sync: 2026-09-22T11:51:20.924956+00:00 --- -# Acp +# ACP harness + +ACP is a way to run an agent that speaks Agent Client Protocol v1 over a local +stdio process. OpenSymphony still owns issue scheduling, workspaces, retries, +and operator decisions. The agent process works inside the issue's checkout. +ACP is an execution choice: it can run a single-repository issue or a child in +a [multi-repository project](multi-repository.md). + +```mermaid +sequenceDiagram + participant S as OpenSymphony scheduler + participant H as ACP session host + participant A as Local ACP agent + participant O as Operator client + S->>H: Start turn in the issue checkout + H->>A: initialize, session/new, session/prompt + A-->>H: Updates and optional permission request + H-->>S: Bound run events and pending request + S-->>O: Run detail and request options + O->>S: Answer for the current run + S->>H: Deliver bound answer + H->>A: ACP callback response + A-->>H: Prompt result + H-->>S: Outcome and retained session state +``` + +## Configure a local agent + +Install the agent's CLI and complete its own login first. Then add a named +profile to the selected central config. This excerpt assumes the rest of the +instance and tracker configuration already exists: + +```yaml +routing: + harness: acp + harness_profile: cursor +acp: + profiles: + cursor: + command: /absolute/path/to/agent + args: [acp] + transport: stdio + protocol_versions: [1] + permissions: + mode: operator +``` + +`routing.harness_profile` chooses a profile ID; the ID is not a vendor switch +in the scheduler. Use an absolute executable path and literal arguments. If +the agent needs environment credentials, map target names to source variable +names under `env_refs`; do not put values in the file or arguments. Omit +`auth` when the CLI's existing login is sufficient. An explicit `auth.method_id` +must be advertised by the agent. + +With `permissions.mode: operator`, a live TUI, web, or desktop client must be +available to answer permission requests. `deny` selects an offered one-time +rejection; `allow_once` selects an offered one-time approval for a trusted +unattended profile. Unsupported options are cancelled rather than guessed. +See [ACP profile configuration](configuration.md#acp-stdio-profiles) for session +model/mode/options, host services, MCP attachments, deadlines, and extension +registration. + +Run `opensymphony run --config ` after selecting the profile. A profile +preflight checks the executable and configuration; the live session negotiates +authentication, persistence, modes, options, and optional methods. Inspect +`/api/v1/capabilities` and the active run detail before offering an operation +to an operator. A profile entry alone does not mean that an optional method is +available. + +## Sessions, recovery, and vendor support + +The session host retains an idle agent process for compatible later turns. +Recovery loads or resumes a known session only when the peer advertised the +method. If a prompt might have been submitted before a disconnect, the run is +fenced for reconciliation; OpenSymphony does not resend that prompt blindly. +Cancel acknowledgement follows a matching stopped prompt result. The +[operations guide](operations.md#acp-client-validation-and-limits) describes +limits and recovery diagnostics. + +The [live qualification report](acp-live-qualification.md) records tracked +issue runs through pinned Cursor and Devin CLIs. Both advertised +`session/load`; neither advertised `session/resume` in that qualification. +Cursor's registered plan and todo methods are limited to its observed pinned +version. Generic ACP v1 profiles do not inherit those vendor methods. + +ACP agents are local processes with trusted host access. Choose the executable, +credentials, and permission policy accordingly. The process runs with its +current directory set to the issue workspace. diff --git a/docs/architecture.md b/docs/architecture.md index 1c3dc1a3e..00ec491bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,14 +2,17 @@ ## 1. Objective -Implement the Symphony orchestration model in Rust while using OpenHands as the -execution substrate and FrankenTUI as an optional operator client. +Implement the Symphony orchestration model in Rust. The scheduler can use the +OpenHands agent-server, the local Codex app-server, or a configured local ACP +v1 agent for execution. FrankenTUI is an optional operator client. The system must preserve these boundaries: - the orchestrator is the source of truth for scheduling state - the tracker is polled and reconciled by the orchestrator -- each issue executes in its own workspace +- each terminal issue executes in its bound repository workspace +- a multi-repository parent uses a separate integration workspace after its + children merge - `WORKFLOW.md` remains the repo-owned policy and prompt contract - UI is optional and must not affect correctness @@ -43,7 +46,7 @@ OpenSymphony is split into five layers: 2. Configuration layer - typed workflow/config loader - env and path resolution - - OpenHands extension config + - project sets, repository inventory, and harness profiles 3. Coordination layer - orchestrator actor - retry queue @@ -54,6 +57,7 @@ OpenSymphony is split into five layers: - OpenHands REST client - OpenHands WebSocket runtime stream - local Codex app-server stdio adapter + - local ACP v1 stdio session host - issue session runner 5. Observability layer - structured logs @@ -67,6 +71,10 @@ Packaging distinction: - the `crates/opensymphony-*` directories are internal module trees compiled into that one package +Start with the [multi-repository guide](multi-repository.md) for issue binding +and parent integration, or the [ACP guide](acp.md) for local agent setup and +operator requests. The two choices are independent. + ## 3. Main decisions ### 3.1 Rust owns orchestration @@ -247,7 +255,7 @@ The completed cleanup intent remains in orchestrator state after the parent root is removed and seeds automatic-capture completion after daemon restart, so a terminal parent is not routed and captured again without first reopening. -### 3.2 OpenHands is the execution adapter +### 3.2 OpenHands adapter OpenHands provides: @@ -260,7 +268,7 @@ OpenHands provides: OpenSymphony does not reimplement an agent loop. -### 3.3 WebSocket-first, not WebSocket-only +### 3.3 OpenHands uses WebSocket and REST REST is still required for: @@ -271,7 +279,7 @@ REST is still required for: - reconnect reconciliation - restart recovery -### 3.4 One local server, many workspaces +### 3.4 One local OpenHands server, many workspaces The local supervised topology runs one OpenHands server for the daemon while passing a distinct `working_dir` per issue. @@ -370,7 +378,7 @@ relations, attachments, project content/status updates, and introspection. ## 5. Process model -Local MVP process graph: +Local process graph when an OpenHands route is selected: ```text opensymphony run @@ -380,16 +388,21 @@ opensymphony run ├─ openhands REST client ├─ openhands WebSocket client ├─ optional Codex app-server stdio worker + ├─ optional ACP v1 stdio session host ├─ gateway API ├─ control-plane compatibility API └─ local server supervisor └─ python -m openhands.agent_server ``` +An ACP-only or Codex-only run does not launch the OpenHands server. + The scheduler attaches a `HarnessRouteDecision` to each worker start request. The default route remains `openhands_agent_server`. Workflow `routing.harness` or the `OPENSYMPHONY_HARNESS` environment override can select the local -`codex_app_server` route when that harness is available and can start runs. +`codex_app_server` or `acp` route when configured and available. ACP also +requires a named `routing.harness_profile`; the scheduler binds the selected +profile to each prepared run. Route decisions are emitted as `routing.decision` runtime audit events so dry-run previews and real dispatches show the selected harness, model, and model profile. diff --git a/docs/configuration.md b/docs/configuration.md index bde506435..31578de62 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,6 +3,11 @@ This document covers target-repo bootstrap, generated files, and the runtime configuration that `opensymphony run` expects. +For a first multi-repository setup, read [multi-repository projects](multi-repository.md) +before the field reference below. For a local ACP agent, start with the +[ACP harness guide](acp.md). Repository routing and harness selection are +independent choices in the same selected config. + Operator projections expose project and repository identity through safe aliases, canonical IDs, and safe remote fingerprints. Credential-bearing remote URLs and raw credential values are excluded. diff --git a/docs/migration-3.0.0.md b/docs/migration-3.0.0.md new file mode 100644 index 000000000..c48921d95 --- /dev/null +++ b/docs/migration-3.0.0.md @@ -0,0 +1,60 @@ +# Upgrading to OpenSymphony 3.0.0 + +OpenSymphony 3.0.0 adds multi-repository project routing and local ACP v1 +worker profiles. Both are explicit choices. An existing single-repository +workflow can continue with `legacy_single` routing and its current harness. + +## Before upgrading + +1. Stop `opensymphony run` and keep the existing `WORKFLOW.md`, `config.yaml`, + workspace root, and memory store until the new run has been checked. +2. Install 3.0.0 with Rust 1.97.1 or newer. Run `opensymphony doctor` in the + target repository to check its local prerequisites. +3. If you want a central configuration, run the read-only migration preflight, + review its findings, and then apply the migration: + + ```bash + opensymphony migrate preflight --repo /path/to/target-repo + opensymphony migrate apply --repo /path/to/target-repo \ + --config /absolute/path/to/config.yaml + ``` + + The generated central config starts with the compatible single-repository + route. Run `opensymphony run --config /absolute/path/to/config.yaml` to + select it explicitly. [Configuration migration](configuration.md#configuration-migration) + describes the backup, activation marker, and rollback checks. + +## Enable multi-repository routing + +Add an active project set, repository inventory, credentials, and review +profiles to the central config. Set `routing.mode: project_set` only after +every terminal issue has exactly one valid `repo:` label and every +parent is repository-neutral. Keep repository-specific instructions in each +checkout and integration instructions in the project set. Follow the +[multi-repository guide](multi-repository.md) for the run flow and the +[configuration model](configuration.md#central-configuration) for the full +schema. + +Before enabling a non-production project set, pass the +[hermetic and disposable live gate](multi-repository-rollout.md) at the same +clean commit and selected config hash. That gate leaves production activation +to a separate operator decision. + +## Select an ACP agent + +ACP changes execution, not repository binding. Install and sign in to a local +ACP v1 CLI, configure a named stdio profile, and select it with +`routing.harness: acp` and `routing.harness_profile`. The +[ACP guide](acp.md) shows a profile and explains operator permission handling. +The negotiated live session determines optional features such as persistence, +model choices, and vendor extensions. + +## Roll back a migrated configuration + +Stop the strict-config instance first. `opensymphony migrate rollback --config +/absolute/path/to/config.yaml` restores the backed-up legacy files only when +the strict-run marker is absent and the migrated memory catalog has not changed +since migration. Preserve the state, workspaces, manifests, and receipts for +any run that has not reached a terminal state. See +[migration recovery](configuration.md#configuration-migration) for the +specific refusal conditions. diff --git a/docs/multi-repository-rollout.md b/docs/multi-repository-rollout.md index 3fe009957..ff24eab77 100644 --- a/docs/multi-repository-rollout.md +++ b/docs/multi-repository-rollout.md @@ -1,5 +1,9 @@ # Multi-repository release gate and isolated rollout +This is the validation procedure for the +[multi-repository project guide](multi-repository.md). Start there for issue +labels, repository inventory, and parent integration. + Strict multi-repository routing stays disabled for production projects until the hermetic gate and one disposable non-production run pass at the same immutable commit, with each selected central config identified by its SHA-256. diff --git a/docs/multi-repository.md b/docs/multi-repository.md new file mode 100644 index 000000000..90483cb0a --- /dev/null +++ b/docs/multi-repository.md @@ -0,0 +1,94 @@ +# Multi-repository projects + +OpenSymphony can run one Linear project across several source repositories. A +terminal issue names one repository with a `repo:` label. A parent issue +has no repository label: it waits for its children to merge, then checks their +combined result in a separate integration workspace. + +The single-repository setup still works. Multi-repository routing is an explicit +choice in the central configuration, and each project set lists the projects +and repositories it may use. [Configuration](configuration.md) defines the +complete file format and [migration](migration-3.0.0.md) covers existing +installations. + +For example, a project with a `core` repository and a `web` repository can +have a core child labeled `repo:core`, a web child labeled `repo:web`, and an +unlabeled parent that checks their merged work together. The aliases come +from the central repository inventory. + +```mermaid +flowchart LR + L["Linear project and issue hierarchy"] --> R["OpenSymphony scheduler"] + C["Central project set and repository inventory"] --> R + R -->|"repo:core"| A["Child checkout: core"] + R -->|"repo:web"| B["Child checkout: web"] + A --> M["Verified child merges"] + B --> M + M --> P["Parent integration workspace"] + P --> V{"Integration checks pass?"} + V -->|yes| D["Capture memory and release workspaces"] + V -->|repair needed| F["One affected-repository repair PR"] + F --> P +``` + +## Set up a project set + +1. Give the instance its own state and workspace roots in the central config. + Select `routing.mode: project_set`, an `active_project_set`, and a tracker + profile. +2. Add each Linear project under `linear_projects` with its provider project + ID and allowed repository aliases. Add each repository once under + `repositories`, with its canonical provider ID, remote, target branch, + checkout credential reference, review profile, and contained instruction + path. The [configuration model](specs/multi-repo-orchestration-spec.md#8-configuration-model) + shows how these sections fit together. Keep credential values in environment + variables referenced by `credentials`. +3. Label every terminal implementation issue with exactly one managed + `repo:` label. Use an alias allowed by its Linear project. Leave + parent issues unlabeled. A missing, duplicate, unknown, or out-of-scope + binding is shown as a routing blocker; OpenSymphony does not choose a + repository from the current directory. +4. Put repository-specific instructions in each checkout's configured file, + such as `AGENTS.md`. Put instructions for checking the combined result in + the project set's `integration_instructions` file. The parent sees each + repository's instructions in a separate section. + +Start the configured instance with `opensymphony run --config `. The +selected config's hash appears in startup diagnostics and its first +control-plane event. + +For an existing single-repository installation, run `opensymphony migrate +preflight --repo ` before `migrate apply`. Review the generated central +config and keep `legacy_single` until the repository inventory, labels, and +project set are ready. Migration does not enable `project_set` by itself. + +## What happens during a run + +OpenSymphony resolves an issue's repository binding before it creates a +workspace. A child runs in a verified checkout of its bound repository. The +scheduler records the selected config and repository inventory generations so +recovery cannot silently move an in-flight issue to another repository. A +binding change stops the old worker and prepares a new generation. + +When all children have provider-confirmed merges, the parent uses retained +child generations to prepare contained worktrees. It runs the configured +integration checks, can open a repair PR in one affected repository, and then +refreshes and verifies the combined result. Terminal cleanup waits for memory +capture and releases worktrees and leases from the leaves upward. The +[workspace lifecycle](workspace-and-lifecycle.md) explains the ownership and +recovery rules in detail. + +## Roll out safely + +Strict multi-repository routing remains an explicit operator choice. Before +enabling it for a project set, run the [hermetic release gate and disposable +live rollout](multi-repository-rollout.md) at one clean commit and record the +selected config hash. The live gate exercises child PRs, a failed check and +same-PR repair, parent verification and repair, and complete teardown in a +non-production project set. Production activation requires a separate +operator decision. + +The desktop and web task graphs show the repository binding and parent +blockers. Run detail shows the selected repository, checkout generation, +integration state, and repair evidence. The gateway exposes the same facts +without credential-bearing remote URLs. diff --git a/docs/operations.md b/docs/operations.md index c5e47354f..318e030f1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,5 +1,10 @@ # Operations +For first-time multi-repository setup, use the +[multi-repository guide](multi-repository.md). For a local ACP agent, use the +[ACP harness guide](acp.md). This page covers the detailed commands and +failure handling after the route is configured. + This document covers the current local operator workflow for OpenSymphony. Run detail and TUI projections expose sanitized repository, target commit, diff --git a/docs/release.md b/docs/release.md index 126867ce1..a503c605e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -160,6 +160,36 @@ last_memory_sync: 2026-07-04T03:35:18.210566+00:00 - COE-615 + +## 3.0.0 multi-repository and ACP release boundary + +Version 3.0.0 includes explicit `project_set` routing and a production local +ACP v1 worker route. Existing `legacy_single` routing remains available. The +[multi-repository guide](multi-repository.md), [ACP guide](acp.md), and +[3.0 upgrade guide](migration-3.0.0.md) explain selection and recovery for +operators new to these paths. + +Before shipping the multi-repository route for a project set, run +`scripts/hermetic_multi_repo_lifecycle.sh` against the selected config at a +clean commit, then run the opt-in disposable live gate. Record the same commit +and selected config SHA-256 in both artifacts, verify exact-head CI, and check +that teardown left no disposable resources. The procedure and bounded rollout +conditions are in [multi-repository rollout](multi-repository-rollout.md). + +Before claiming an ACP vendor profile is qualified, check the executable +version and login, then run the authenticated tracked-issue and restoration +tests in [ACP live qualification](acp-live-qualification.md). Profile preflight +alone does not prove authentication or optional protocol support. The pinned +Cursor and Devin runs qualified `session/load`; neither advertised +`session/resume` in that run. + +Keep the root crate, desktop package, Tauri metadata, and both Cargo lockfiles +at `3.0.0`. The root `package-lock.json` must record the desktop package at +the same version. Run the default bundled-mode Clippy and tests, frontend +build, and `npm run package:release --workspace=@opensymphony/desktop -- +--dry-run` before tagging or publishing. The dry run checks desktop version +parity without writing release assets. + ## 2.11 Rust toolchain boundary OpenSymphony 2.11.0 requires Rust 1.97.1 for the root CLI and desktop crate and diff --git a/package-lock.json b/package-lock.json index d13c177b7..0ff608d82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@opensymphony/desktop", - "version": "2.11.3", + "version": "3.0.0", "dependencies": { "@opensymphony/api-client": "*", "@opensymphony/gateway-schema": "*", From e43f6f371012b0e84cb733f2c18e4a20b4a6855b Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 26 Sep 2026 13:53:13 -0500 Subject: [PATCH 26/29] docs: align overview with available harnesses --- README.md | 6 +++--- docs/release.md | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3ca2b06f5..a730122f0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ OpenSymphony automates software development workflows by: 1. **Polling Linear** for issues in active states (Todo, In Progress, etc.) 2. **Creating isolated workspaces** for each issue with lifecycle hooks -3. **Dispatching AI agents** via OpenHands or Codex to work on issues autonomously +3. **Dispatching AI agents** through OpenHands, Codex, or a configured ACP profile 4. **Managing retries, reconciliation, and cleanup** based on issue state changes 5. **Providing a desktop operator dashboard** for task graph, run detail, and memory navigation @@ -18,9 +18,9 @@ OpenSymphony automates software development workflows by: - **Hierarchy-aware scheduling**: Parent issues wait for sub-issues to complete - **Multi-repository projects**: Each child names one repository; a repository-neutral parent checks the merged result -- **WebSocket-first runtime**: Real-time agent updates with REST reconciliation +- **Live runtime updates**: OpenHands uses WebSocket with REST reconciliation; Codex and ACP use local stdio - **Per-issue workspaces**: Deterministic, isolated directories with lifecycle hooks -- **GraphQL-only Linear integration**: Agent-side Linear reads and writes through checked-in helper/query assets +- **GraphQL-only Linear integration**: The scheduler polls Linear; agent-side writes use checked-in helper/query assets - **Dependency-aware Task Graph**: Shows dispatchable work, roadmap backlog, and selected-task critical paths - **Harness selection**: OpenHands, local Codex app-server, or a configured ACP v1 stdio agent - **Code Graph**: Tree-sitter-backed symbols, diagnostics, and source-cited structural context for agents diff --git a/docs/release.md b/docs/release.md index a503c605e..62844977a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -186,9 +186,13 @@ Cursor and Devin runs qualified `session/load`; neither advertised Keep the root crate, desktop package, Tauri metadata, and both Cargo lockfiles at `3.0.0`. The root `package-lock.json` must record the desktop package at the same version. Run the default bundled-mode Clippy and tests, frontend -build, and `npm run package:release --workspace=@opensymphony/desktop -- ---dry-run` before tagging or publishing. The dry run checks desktop version -parity without writing release assets. +build, and the desktop parity guard before tagging or publishing: + +```bash +npm run package:release --workspace=@opensymphony/desktop -- --dry-run +``` + +The dry run checks desktop version parity without writing release assets. ## 2.11 Rust toolchain boundary From 70a5aa63b02c5f6b44ec93b37a64fcc3d1b44b9d Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 26 Sep 2026 13:56:12 -0500 Subject: [PATCH 27/29] docs: correct migration destination and repository binding --- README.md | 2 +- docs/migration-3.0.0.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a730122f0..caa0372a4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ OpenSymphony automates software development workflows by: ### Key Features - **Hierarchy-aware scheduling**: Parent issues wait for sub-issues to complete -- **Multi-repository projects**: Each child names one repository; a repository-neutral parent checks the merged result +- **Multi-repository projects**: Each terminal child names one repository; repository-neutral parents check merged results - **Live runtime updates**: OpenHands uses WebSocket with REST reconciliation; Codex and ACP use local stdio - **Per-issue workspaces**: Deterministic, isolated directories with lifecycle hooks - **GraphQL-only Linear integration**: The scheduler polls Linear; agent-side writes use checked-in helper/query assets diff --git a/docs/migration-3.0.0.md b/docs/migration-3.0.0.md index c48921d95..4c32c3309 100644 --- a/docs/migration-3.0.0.md +++ b/docs/migration-3.0.0.md @@ -16,7 +16,7 @@ workflow can continue with `legacy_single` routing and its current harness. ```bash opensymphony migrate preflight --repo /path/to/target-repo opensymphony migrate apply --repo /path/to/target-repo \ - --config /absolute/path/to/config.yaml + --output /absolute/path/to/config.yaml ``` The generated central config starts with the compatible single-repository From 6aeb449143967eb973ab838c8b2e28e81c4987dd Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 26 Sep 2026 14:02:20 -0500 Subject: [PATCH 28/29] docs: preflight the selected migration output --- docs/migration-3.0.0.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/migration-3.0.0.md b/docs/migration-3.0.0.md index 4c32c3309..29cc2689f 100644 --- a/docs/migration-3.0.0.md +++ b/docs/migration-3.0.0.md @@ -14,7 +14,8 @@ workflow can continue with `legacy_single` routing and its current harness. review its findings, and then apply the migration: ```bash - opensymphony migrate preflight --repo /path/to/target-repo + opensymphony migrate preflight --repo /path/to/target-repo \ + --output /absolute/path/to/config.yaml opensymphony migrate apply --repo /path/to/target-repo \ --output /absolute/path/to/config.yaml ``` From a6543e2fb859ad83c1d08326e499868c2491b018 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Sat, 26 Sep 2026 14:09:48 -0500 Subject: [PATCH 29/29] docs: explain repository work with parent and sub-issues --- README.md | 2 +- docs/architecture.md | 4 ++-- docs/migration-3.0.0.md | 4 ++-- docs/multi-repository.md | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index caa0372a4..9a81de415 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ OpenSymphony automates software development workflows by: ### Key Features - **Hierarchy-aware scheduling**: Parent issues wait for sub-issues to complete -- **Multi-repository projects**: Each terminal child names one repository; repository-neutral parents check merged results +- **Multi-repository projects**: Repository-bound sub-issues run in their own checkouts; an unlabeled parent checks their merged work - **Live runtime updates**: OpenHands uses WebSocket with REST reconciliation; Codex and ACP use local stdio - **Per-issue workspaces**: Deterministic, isolated directories with lifecycle hooks - **GraphQL-only Linear integration**: The scheduler polls Linear; agent-side writes use checked-in helper/query assets diff --git a/docs/architecture.md b/docs/architecture.md index 00ec491bd..823f11ec3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ The system must preserve these boundaries: - the orchestrator is the source of truth for scheduling state - the tracker is polled and reconciled by the orchestrator -- each terminal issue executes in its bound repository workspace +- each repository-bound work issue executes in its own checkout - a multi-repository parent uses a separate integration workspace after its children merge - `WORKFLOW.md` remains the repo-owned policy and prompt contract @@ -100,7 +100,7 @@ including when a subtree no longer requires merge evidence. Reopening or recovering nonterminal work invalidates its durable success receipt before batched launch preparation. -Repository routing is also orchestrator-owned: terminal child metadata carries +Repository routing is also orchestrator-owned: repository-bound child metadata carries one alias, the central inventory resolves it to a canonical provider identity, and the scheduler persists that identity plus its config and inventory generations before claiming work. Project associations are validation scope, diff --git a/docs/migration-3.0.0.md b/docs/migration-3.0.0.md index 29cc2689f..26bb738ee 100644 --- a/docs/migration-3.0.0.md +++ b/docs/migration-3.0.0.md @@ -29,8 +29,8 @@ workflow can continue with `legacy_single` routing and its current harness. Add an active project set, repository inventory, credentials, and review profiles to the central config. Set `routing.mode: project_set` only after -every terminal issue has exactly one valid `repo:` label and every -parent is repository-neutral. Keep repository-specific instructions in each +every implementation sub-issue has exactly one valid `repo:` label and +its parent has no repository label. Keep repository-specific instructions in each checkout and integration instructions in the project set. Follow the [multi-repository guide](multi-repository.md) for the run flow and the [configuration model](configuration.md#central-configuration) for the full diff --git a/docs/multi-repository.md b/docs/multi-repository.md index 90483cb0a..e8e97ed76 100644 --- a/docs/multi-repository.md +++ b/docs/multi-repository.md @@ -1,9 +1,9 @@ # Multi-repository projects -OpenSymphony can run one Linear project across several source repositories. A -terminal issue names one repository with a `repo:` label. A parent issue -has no repository label: it waits for its children to merge, then checks their -combined result in a separate integration workspace. +OpenSymphony can run one Linear project across several source repositories. +Each implementation sub-issue names one repository with a `repo:` label. +Its parent has no repository label: it waits for the sub-issues to merge, then +checks their combined result in a separate integration workspace. The single-repository setup still works. Multi-repository routing is an explicit choice in the central configuration, and each project set lists the projects @@ -43,7 +43,7 @@ flowchart LR path. The [configuration model](specs/multi-repo-orchestration-spec.md#8-configuration-model) shows how these sections fit together. Keep credential values in environment variables referenced by `credentials`. -3. Label every terminal implementation issue with exactly one managed +3. Label every implementation sub-issue with exactly one managed `repo:` label. Use an alias allowed by its Linear project. Leave parent issues unlabeled. A missing, duplicate, unknown, or out-of-scope binding is shown as a routing blocker; OpenSymphony does not choose a