diff --git a/.agent/docs/architecture/agent-orchestrator.md b/.agent/docs/architecture/agent-orchestrator.md index d544ee96..92b2a1cc 100644 --- a/.agent/docs/architecture/agent-orchestrator.md +++ b/.agent/docs/architecture/agent-orchestrator.md @@ -145,14 +145,34 @@ the same compact table style while preserving their hidden durable markers. If terminal child metadata is found but rejected by trust checks or cannot be safely updated, the dispatcher posts a compact stop comment on the current terminal issue or PR with a hidden dedupe marker. Ordinary terminal PR stops -without sub-orchestrator metadata remain silent. +without sub-orchestrator metadata post or update one finalized orchestration +note. The note summarizes the source action and conclusion, target, round, +reason, source run, and any planner-provided user message. It mentions the +original requester only when the requester is a human-looking GitHub login, +never the configured agent handle or a bot identity. When orchestration +reporting is explicitly enabled, a deterministic write-scoped job publishes a +non-cancellable progress note in parallel with the read-only planner. The +resolver updates that note and supersedes any older final marker; otherwise it +upserts the final note by the hidden `sepo-agent-orchestrate-final` marker. +Trusted notes carrying the legacy `sepo-agent-orchestrate-stop` marker are +updated in place and rewritten with the current marker during the next terminal +run. If the resumed parent planner decides there is no next child or action, the parent run posts a terminal stop comment on the parent issue with the source -conclusion, target, round, reason, and hidden `sepo-agent-orchestrate-stop` -marker. Exact trusted duplicates are skipped on reruns. +conclusion, target, round, reason, and hidden `sepo-agent-orchestrate-final` +marker. Reruns update the trusted marker comment instead of posting duplicates. When the planner returns `blocked` with `user_message` or `clarification_request`, that same terminal comment surfaces the planner's question directly and the chain pauses without dispatching an `answer` route. +After a PR ends with `review`/`SHIP`, `agent-self-approve`/`approved`, or +`agent-self-merge`/`merged` or `auto_merge_enabled`, the dispatcher also marks +older trusted review synthesis, rubrics review, fix-pr status, and handoff +comments as outdated. In agent mode, success wording and cleanup also require a +validated terminal `stop` decision from the planner; missing or malformed +planner responses retain the artifacts and report a non-successful stop. +Finalized orchestration notes are excluded from every cleanup matcher, and +cleanup is skipped when the planner blocks or requests clarification. +`AGENT_COLLAPSE_OLD_REVIEWS=false` disables that cleanup. Initial user-launched `/orchestrate` requests validate that the requester has access to the delegated route capability set before dispatching work. When @@ -180,7 +200,7 @@ In `heuristics` mode, action-originated handoff decisions still use the fixed tr Review-originated `fix-pr` handoffs carry explicit task context when available. The review dispatcher derives it from the latest review synthesis action items, and heuristic mode falls back to a conservative instruction to address only unresolved review synthesis action items while ignoring optional INFO notes and metadata-only polish. When a review synthesis recommends `HUMAN_DECISION`, self-approval-enabled orchestration routes to `agent-self-approve` instead of `fix-pr` or a human stop; self-approval then decides whether to approve, request changes, or block. Manual PR `/orchestrate` starts with a `CHANGES_REQUESTED` review decision use separate context that tells `fix-pr` to address the latest unresolved requested-change review comments instead of the review-synthesis fallback. Self-approval `REQUEST_CHANGES` handoffs preserve the approval agent's handoff context as the `fix-pr` task. Self-approval `APPROVED` handoffs dispatch `agent-self-merge` only when `AGENT_ALLOW_SELF_MERGE=true`. -In `agent` mode, the orchestrator first runs a scoped planner prompt through the same resolved-provider runtime used by other agent actions. The planner has its own `orchestrator` route and `planner` lane, so session continuation is separate from implement, review, and fix-pr sessions. The planner runs with `approve-all` tool permission so it can gather current GitHub and repository context in non-interactive workflows. It still receives read-only repository memory, selected read-only rubrics, the handoff envelope, any source handoff context, and original request, and returns JSON describing whether to stop, block, delegate a child issue, or hand off. For blocked decisions, the planner may return `user_message` or `clarification_request` to ask for missing context in the visible stop comment. For handoffs, the planner may also return `handoff_context`: explicit, action-oriented instructions for the next workflow. When the next action is `fix-pr`, the dispatcher passes that context into `agent-fix-pr.yml`, and the fix-pr prompt treats it as the selected task and constraints for the automated fix pass. The workflow uses the runtime preflight CLI to skip this planner when the max-round budget is already exhausted or the initial requester lacks delegated-route capability, and the runtime still validates planner JSON against the fixed transition policy, the issue-only direct-implement rule, and max-round budget before dispatching anything. +In `agent` mode, the orchestrator first runs a scoped planner prompt through the same resolved-provider runtime used by other agent actions. The planner has its own `orchestrator` route and `planner` lane, so session continuation is separate from implement, review, and fix-pr sessions. The planner runs with `approve-all` tool permission so it can gather current GitHub and repository context in non-interactive workflows, but its job and GitHub token remain read-only. The planner-local progress reporter is explicitly disabled. If `AGENT_PROGRESS_POLICY.orchestration_mode` is `report-only`, a parallel deterministic job resolves write-capable authentication and publishes the progress note; publication failures remain visible while the final-marker fallback still runs. The planner uploads its JSON response as a short-lived artifact and uploads any configured session bundle without registering it. A separate deterministic job downloads the response, resolves write-capable GitHub authentication, persists the planner thread state and bundle registration for the next round, validates the transition, and owns final comments, minimization, and workflow dispatch. The planner still receives read-only repository memory, selected read-only rubrics, the handoff envelope, any source handoff context, and original request, and returns JSON describing whether to stop, block, delegate a child issue, or hand off. For blocked decisions, the planner may return `user_message` or `clarification_request` to ask for missing context in the visible stop comment. For handoffs, the planner may also return `handoff_context`: explicit, action-oriented instructions for the next workflow. When the next action is `fix-pr`, the dispatcher passes that context into `agent-fix-pr.yml`, and the fix-pr prompt treats it as the selected task and constraints for the automated fix pass. The workflow uses the runtime preflight CLI to skip this planner when the max-round budget is already exhausted or the initial requester lacks delegated-route capability, and the runtime still validates planner JSON against the fixed transition policy, the issue-only direct-implement rule, and max-round budget before dispatching anything. When an orchestrator-launched `implement` or `fix-pr` run reports `no_changes`, `failed`, `verify_failed`, or `unsupported`, the dispatcher stops @@ -194,7 +214,16 @@ Before dispatching, the orchestrator checks for a hidden handoff marker on the d ## Permission note -`agent-orchestrator.yml` requests `actions: write` because `workflow_dispatch` requires it, and `issues: write` to persist dedupe markers on destination issues or pull requests. +The planner job grants only read access to actions, contents, issues, and pull +requests. A model-free progress job requests issue and pull-request write access +only for configured report-only publication. The separate deterministic +decision job requests `actions: write` for `workflow_dispatch`, `issues: write` +for issue markers, and `pull-requests: write` for finalized PR notes and trusted +artifact cleanup. It also requests `contents: write` only for deterministic +planner thread-state and session-bundle metadata refs; that credential is never +passed to the planner model. The decision job uses a cancellation-aware +condition while still running after ordinary planner or artifact-transfer +failures so it can publish a safe stop. ## Extension path diff --git a/.agent/docs/architecture/request-lifecycle.md b/.agent/docs/architecture/request-lifecycle.md index efb1e4db..164c69a1 100644 --- a/.agent/docs/architecture/request-lifecycle.md +++ b/.agent/docs/architecture/request-lifecycle.md @@ -26,7 +26,7 @@ By default, an explicit mention without a slash command resolves locally to `ans PR fix requests never create a tracking issue or a new pull request. The runner updates the existing PR branch after reading PR metadata and review comments. Dirty worktree changes are committed and pushed back to the PR branch; clean history-only updates, such as a successful rebase, run verification against the original PR head and then push the updated `HEAD` back to the PR branch with a lease against that original head. If persistence fails after a successful agent run, the final status comment reports the run as failed. Automatic pushing is limited to open same-repository pull requests, and route access follows the configured trigger access policy. -Direct implementation and PR-fix runs publish a live progress comment on issue and pull request surfaces by default, while answer runs use report-only progress by default. The shared `run-agent-task` action starts a best-effort reporter before the agent run, passes it the agent's ACP stream file, and tears it down after the run. If the reporter created a comment, the final issue/PR response step patches that same comment with the substantive result first and a collapsed activity log below it; otherwise it falls back to posting the normal final comment. An authorized 👎 reaction from the requester, repository owner, member, or collaborator first marks cancellable progress comments as cancelled, then requests GitHub Actions cancellation. `AGENT_PROGRESS_POLICY` can disable the comment or switch a route to `report-only`; review runs remain disabled by default. Runs with explicit orchestration context ignore normal route progress overrides, default to no progress comment, and report state through orchestrator handoff or status comments unless `orchestration_mode` explicitly opts into report-only progress. +Direct implementation and PR-fix runs publish a live progress comment on issue and pull request surfaces by default, while answer runs use report-only progress by default. The shared `run-agent-task` action starts a best-effort reporter before the agent run, passes it the agent's ACP stream file, and tears it down after the run. If the reporter created a comment, the final issue/PR response step patches that same comment with the substantive result first and a collapsed activity log below it; otherwise it falls back to posting the normal final comment. An authorized 👎 reaction from the requester, repository owner, member, or collaborator first marks cancellable progress comments as cancelled, then requests GitHub Actions cancellation. `AGENT_PROGRESS_POLICY` can disable the comment or switch a route to `report-only`; review runs remain disabled by default. Runs with explicit orchestration context ignore normal route progress overrides, default to no progress comment, and report state through orchestrator handoff or status comments unless `orchestration_mode` explicitly opts into report-only progress. The orchestrator planner itself remains read-only and disables its local reporter; a separate deterministic write-scoped job publishes its configured report-only note and surfaces publication failures before the resolver finalizes or falls back from that note. ## Branch naming diff --git a/.agent/docs/customization/configuration-list.md b/.agent/docs/customization/configuration-list.md index ad958a43..53ccf61f 100644 --- a/.agent/docs/customization/configuration-list.md +++ b/.agent/docs/customization/configuration-list.md @@ -85,7 +85,7 @@ The bundled workflows still keep native YAML escape hatches: an inline `route_pr } ``` -`enabled` starts the progress comment and allows authorized 👎 cancellation. `report-only` starts the progress comment but ignores cancellation reactions. `disabled` preserves the normal run without a progress comment. Malformed policy disables progress for that run instead of failing the workflow. Orchestrated chains default to `disabled` progress mode and rely on handoff or status comments; set `orchestration_mode` to `report-only` to opt into non-cancellable progress comments for orchestrated runs. `enabled` is not accepted for `orchestration_mode` because cancellable chained-run semantics are not defined. +`enabled` starts the progress comment and allows authorized 👎 cancellation. `report-only` starts the progress comment but ignores cancellation reactions. `disabled` preserves the normal run without a progress comment. Malformed policy disables progress for that run instead of failing the workflow. Orchestrated chains default to `disabled` progress mode and rely on handoff or status comments; set `orchestration_mode` to `report-only` to opt into non-cancellable progress comments for orchestrated runs. The orchestrator planner keeps its GitHub token read-only, so a parallel model-free write job publishes that configured note and the resolver finalizes it. `enabled` is not accepted for `orchestration_mode` because cancellable chained-run semantics are not defined. ## Repository secrets diff --git a/.agent/docs/usage/internal-actions.md b/.agent/docs/usage/internal-actions.md index 949e9eeb..1fb369f1 100644 --- a/.agent/docs/usage/internal-actions.md +++ b/.agent/docs/usage/internal-actions.md @@ -11,7 +11,7 @@ Internal actions are shared composite GitHub Actions under `.github/actions/`. T | `.github/actions/resolve-agent-provider` | Resolves the provider, pinned default model, and optional reasoning effort for single-agent runs, reviewer lanes, and review synthesis before runtime setup | `route`, `route_provider`, `default_provider`, `model_policy`, `openai_api_key`, `claude_oauth_token`, `anthropic_api_key`, `required` | outputs `provider`, `reason`, `install_codex`, `install_claude`, `model`, and `reasoning_effort`; selects explicit inline overrides, route provider overrides from `AGENT_MODEL_POLICY`, or `AGENT_DEFAULT_PROVIDER`, otherwise auto-detects from configured provider secrets; uses Sepo's built-in provider model default unless `AGENT_MODEL_POLICY` overrides it | | `.github/actions/check-agent-action-expiration` | Shared expiration guard for generated scheduled agent workflows | `expires_at` | outputs `expired`, `expires_at`, and `today`; validates a UTC `YYYY-MM-DD` expiration and skips generated workflows after that date without relying on GNU-only `date -d` parsing | | `.github/actions/run-skill-setup` | Checks a repository skill and runs its optional `setup.sh` hook | `skill`, `skill_root`, `trusted_ref`, `run_setup` | outputs `exists`, `skill_path`, `setup_exists`, `setup_ran`, and `setup_path`; refuses setup from untrusted PR checkout refs | -| `.github/actions/run-agent-task` | Runs a prompt or skill through the runtime and `acpx` | `prompt`, `skill`, `agent`, `model`, `display_model`, `reasoning_effort`, `route`, `agent_cwd`, `lane`, `target_*`, `source_kind`, `request_source_kind`, `request_comment_id`, `request_comment_url`, `request_text`, `session_policy`, `session_bundle_mode`, `memory_policy`, `memory_mode_override`, `memory_ref`, `rubrics_policy`, `rubrics_mode_override`, `rubrics_ref`, `rubrics_limit` | renders the prompt, runs `.agent/dist/run.js` from `agent_cwd` when provided, captures response/session files, exposes `model_display` when enabled, passes configured model-provider credentials through, restores and uploads session bundles when enabled, resolves memory/rubrics modes, optionally mounts `agent/memory` and `agent/rubrics`, and commits permitted memory or validated rubric edits | +| `.github/actions/run-agent-task` | Runs a prompt or skill through the runtime and `acpx` | `prompt`, `skill`, `agent`, `model`, `display_model`, `reasoning_effort`, `route`, `agent_cwd`, `lane`, `target_*`, `source_kind`, `request_source_kind`, `request_comment_id`, `request_comment_url`, `request_text`, `session_policy`, `session_bundle_mode`, `defer_session_state_persistence`, `memory_policy`, `memory_mode_override`, `memory_ref`, `rubrics_policy`, `rubrics_mode_override`, `rubrics_ref`, `rubrics_limit` | renders the prompt, runs `.agent/dist/run.js` from `agent_cwd` when provided, captures response/session files, exposes `model_display` when enabled, passes configured model-provider credentials through, restores and uploads session bundles when enabled, can defer thread-state and bundle-registration writes to a separately authorized caller, resolves memory/rubrics modes, optionally mounts `agent/memory` and `agent/rubrics`, and commits permitted memory or validated rubric edits | | `.github/actions/download-agent-memory` | Best-effort shallow clone of the repo-local `agent/memory` branch into `$RUNNER_TEMP/agent-memory` so the agent can read and write memory without staging it on the feature branch | `github_token`, `ref`, `path`, `continue_on_missing` | outputs `memory_available`, `memory_dir`, `memory_ref` | `resolve-github-auth` keeps deterministic auth misconfiguration failures explicit. The hosted OIDC broker path retries short-lived transport failures and broker HTTP `429`, `500`, `502`, `503`, or `504` responses with bounded backoff; generic broker `400` responses remain terminal so request/auth bugs are not hidden. diff --git a/.agent/docs/usage/supported-workflows.md b/.agent/docs/usage/supported-workflows.md index d89a6113..dd068ee8 100644 --- a/.agent/docs/usage/supported-workflows.md +++ b/.agent/docs/usage/supported-workflows.md @@ -71,9 +71,21 @@ Planner-based selection is also used for action-originated handoff runs. The pla `handoff_context` string for the next action; `fix-pr` receives it as explicit initial steering when the planner dispatches a PR-fix pass. The planner mounts memory and rubrics read-only so automated control-flow planning can use steering -context without mutating those state branches. Orchestration stops when target -state indicates no safe next action, a route fails, a duplicate handoff marker +context without mutating those state branches, and it receives a read-only +GitHub token. A separately permissioned deterministic job validates and applies +the uploaded planner response, persists planner thread state, and registers any +uploaded session bundle before dispatching the next round. Orchestration stops +when target state indicates no safe next action, a route fails, a duplicate handoff marker is found, the planner stops or blocks, or the max-round budget is exhausted. +Terminal PR runs publish one marker-upserted finalized note that summarizes the +outcome and mentions the original requester only for human GitHub logins. When +orchestration `report-only` progress is enabled, a separate model-free, +write-scoped job publishes that note while the planner retains a read-only +token. The deterministic resolver turns it into the finalized note instead of +creating a separate comment; publication failures are visible and fall back to +the final marker note. +Trusted legacy `sepo-agent-orchestrate-stop` notes are migrated in place to the +current `sepo-agent-orchestrate-final` marker. When a child issue reaches a terminal stop, the handoff dispatcher resolves the trusted child metadata from the issue body or an agent-authored child issue @@ -122,6 +134,9 @@ HTML markers for robust matching, with heading/text fallbacks for older comments. Rubrics reviews match the `## Rubrics Review` heading, and orchestrator handoffs match their hidden handoff marker. This keeps the latest generated status prominent while leaving older generated comments expandable. +Successful terminal PR orchestration also collapses these trusted review and +handoff artifacts after publishing its finalized note, except when the planner +blocks or requests clarification. Finalized notes are never cleanup candidates. Set `AGENT_COLLAPSE_OLD_REVIEWS=false` to skip this cleanup and leave prior generated comments visible. diff --git a/.agent/src/__tests__/envelope.test.ts b/.agent/src/__tests__/envelope.test.ts index 0e80190b..723b9a86 100644 --- a/.agent/src/__tests__/envelope.test.ts +++ b/.agent/src/__tests__/envelope.test.ts @@ -973,6 +973,169 @@ test("self-approval workflow stays opt-in and read-only until deterministic reso assert.match(workflowText, /node \.agent\/dist\/cli\/dispatch-agent-orchestrator\.js/); }); +test("orchestrator planner stays read-only until deterministic resolution", () => { + const workflow = parseYaml(readRepoFile(".github/workflows/agent-orchestrator.yml")) as unknown; + assert.ok(isRecord(workflow), "orchestrator workflow should parse as a YAML object"); + assert.ok(isRecord(workflow.jobs), "orchestrator workflow should define jobs"); + + const planJob = workflow.jobs.plan; + assert.ok(isRecord(planJob), "orchestrator workflow should define a plan job"); + assert.ok(isRecord(planJob.permissions), "plan job should define permissions"); + assert.deepEqual(planJob.permissions, { + actions: "read", + contents: "read", + issues: "read", + "pull-requests": "read", + }); + assert.ok(isRecord(planJob.outputs), "plan job should expose deferred session metadata"); + assert.equal(planJob.outputs.agent_exit_code, "${{ steps.planner.outputs.agent_exit_code }}"); + assert.equal(planJob.outputs.thread_key, "${{ steps.planner.outputs.thread_key }}"); + assert.equal( + planJob.outputs.session_bundle_artifact_id, + "${{ steps.planner.outputs.session_bundle_artifact_id }}", + ); + assert.ok(Array.isArray(planJob.steps), "plan job should define steps"); + const plannerStep = planJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Plan next action with agent", + ); + assert.ok(plannerStep, "plan job should run the orchestrator planner"); + assert.ok(isRecord(plannerStep.with), "planner step should define inputs"); + assert.equal(plannerStep.with.permission_mode, "approve-all"); + assert.equal(plannerStep.with.github_token, "${{ github.token }}"); + assert.equal(plannerStep.with.defer_session_state_persistence, "true"); + assert.equal(plannerStep.with.progress_policy, '{"orchestration_mode":"disabled"}'); + assert.equal( + planJob.steps.some((step) => isRecord(step) && step.name === "Resolve GitHub auth"), + false, + ); + + const progressJob = workflow.jobs.progress; + assert.ok(isRecord(progressJob), "orchestrator workflow should define a trusted progress job"); + assert.equal( + progressJob.if, + "${{ vars.AGENT_ENABLED != 'false' && vars.AGENT_PROGRESS_POLICY != '' && !cancelled() }}", + ); + assert.ok(isRecord(progressJob.permissions), "progress job should define permissions"); + assert.deepEqual(progressJob.permissions, { + contents: "read", + issues: "write", + "pull-requests": "write", + "id-token": "write", + }); + assert.ok(Array.isArray(progressJob.steps), "progress job should define steps"); + assert.equal( + progressJob.steps.some((step) => + isRecord(step) && step.uses === "./.github/actions/run-agent-task" + ), + false, + ); + const progressPolicyStep = progressJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Resolve orchestration progress policy", + ); + assert.ok(progressPolicyStep, "trusted progress job should resolve orchestration policy"); + assert.ok(isRecord(progressPolicyStep.env), "progress policy step should define environment inputs"); + assert.equal(progressPolicyStep.env.ORCHESTRATION_ENABLED, "true"); + assert.equal(progressPolicyStep.env.AGENT_PROGRESS_POLICY, "${{ vars.AGENT_PROGRESS_POLICY || '' }}"); + const progressPublishStep = progressJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Publish orchestration progress", + ); + assert.ok(progressPublishStep, "trusted progress job should publish configured progress"); + assert.ok(isRecord(progressPublishStep.env), "progress publisher should define environment inputs"); + assert.equal(progressPublishStep.env.GH_TOKEN, "${{ steps.auth.outputs.token }}"); + assert.match(String(progressPublishStep.run), /publish-orchestration-progress\.js/); + + const resolverJob = workflow.jobs["decide-and-dispatch"]; + assert.ok(isRecord(resolverJob), "orchestrator workflow should define a deterministic resolver job"); + assert.deepEqual(resolverJob.needs, ["plan", "progress"]); + assert.equal( + resolverJob.if, + "${{ vars.AGENT_ENABLED != 'false' && !cancelled() }}", + ); + assert.ok(isRecord(resolverJob.permissions), "resolver job should define permissions"); + assert.deepEqual(resolverJob.permissions, { + actions: "write", + contents: "write", + issues: "write", + "pull-requests": "write", + "id-token": "write", + }); + assert.ok(Array.isArray(resolverJob.steps), "resolver job should define steps"); + const persistSessionStateStep = resolverJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Persist planner thread state", + ); + assert.ok(persistSessionStateStep, "resolver should persist planner state deterministically"); + assert.equal(persistSessionStateStep["continue-on-error"], true); + assert.ok(isRecord(persistSessionStateStep.env), "state persistence should define environment inputs"); + assert.equal(persistSessionStateStep.env.INPUT_GITHUB_TOKEN, "${{ steps.auth.outputs.token }}"); + assert.equal(persistSessionStateStep.env.THREAD_KEY, "${{ needs.plan.outputs.thread_key }}"); + assert.equal(persistSessionStateStep.run, "node .agent/dist/cli/session-persist.js"); + + const registerSessionBundleStep = resolverJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Register planner session bundle", + ); + assert.ok(registerSessionBundleStep, "resolver should register planner bundles deterministically"); + assert.equal(registerSessionBundleStep["continue-on-error"], true); + assert.ok(isRecord(registerSessionBundleStep.env), "bundle registration should define inputs"); + assert.equal(registerSessionBundleStep.env.INPUT_GITHUB_TOKEN, "${{ steps.auth.outputs.token }}"); + assert.equal( + registerSessionBundleStep.env.SESSION_BUNDLE_ARTIFACT_ID, + "${{ needs.plan.outputs.session_bundle_artifact_id }}", + ); + assert.equal(registerSessionBundleStep.run, "node .agent/dist/cli/session-register.js"); + + const downloadStep = resolverJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Download planner response", + ); + assert.ok(downloadStep, "resolver should download the planner response"); + assert.equal(downloadStep.id, "download_planner_response"); + assert.equal(downloadStep["continue-on-error"], true); + assert.equal( + downloadStep.if, + "${{ !cancelled() && needs.plan.outputs.planner_response_artifact_id != '' }}", + ); + + const locateStep = resolverJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Locate planner response", + ); + assert.ok(locateStep, "resolver should locate the planner response"); + assert.equal(locateStep["continue-on-error"], true); + assert.equal( + locateStep.if, + "${{ !cancelled() && needs.plan.outputs.planner_response_artifact_id != '' && steps.download_planner_response.outcome == 'success' }}", + ); + + const resolverStep = resolverJob.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Decide and dispatch next action", + ); + assert.ok(resolverStep, "resolver job should apply the planner decision deterministically"); + assert.equal( + resolverStep.if, + "${{ !cancelled() && steps.resolver_checkout.outcome == 'success' && steps.auth.outcome == 'success' && steps.resolver_runtime.outcome == 'success' }}", + ); + assert.ok(isRecord(resolverStep.env), "resolver step should define environment inputs"); + assert.equal( + resolverStep.env.PLANNER_RESPONSE_FILE, + "${{ steps.planner_response.outputs.response_file }}", + ); + assert.equal( + resolverStep.env.AGENT_PROGRESS_COMMENT_ID, + "${{ needs.progress.outputs.progress_comment_id }}", + ); + assert.equal( + resolverStep.env.AGENT_PROGRESS_STREAM_FILE, + "${{ steps.planner_response.outputs.session_log_file }}", + ); + assert.equal(resolverStep.env.GH_TOKEN, "${{ steps.auth.outputs.token }}"); +}); + test("self-merge workflow stays opt-in and deterministic", () => { const workflowText = readRepoFile(".github/workflows/agent-self-merge.yml"); const workflow = parseYaml(workflowText) as unknown; @@ -1819,6 +1982,7 @@ test("shared run-agent-task action wires session bundle restore and upload aroun assert.match(action, /session_bundle_mode:/); assert.match(action, /session_bundle_retention_days:/); + assert.match(action, /defer_session_state_persistence:/); assert.match(action, /session_fork_from_thread_key:/); assert.match(action, /Restore session bundle/); assert.match(action, /Restore session bundle[\s\S]*continue-on-error:\s*true/); @@ -1846,7 +2010,28 @@ test("shared run-agent-task action wires session bundle restore and upload aroun assert.ok(runStep, "run-agent-task action should include the Run agent task step"); assert.ok(isRecord(runStep.env), "Run agent task step should define env"); assert.equal(runStep.env.SESSION_BUNDLE_MODE, "${{ inputs.session_bundle_mode }}"); + assert.equal( + runStep.env.DEFER_SESSION_STATE_PERSISTENCE, + "${{ inputs.defer_session_state_persistence }}", + ); + const restoreStep = parsedAction.runs.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Restore session bundle", + ); + assert.ok(restoreStep, "run-agent-task should restore prior session bundles"); + assert.ok(isRecord(restoreStep.env), "session restore step should define env"); + assert.equal( + restoreStep.env.DEFER_SESSION_STATE_PERSISTENCE, + "${{ inputs.defer_session_state_persistence }}", + ); + const registerStep = parsedAction.runs.steps.find( + (step): step is Record => + isRecord(step) && step.name === "Register session bundle artifact", + ); + assert.ok(registerStep, "run-agent-task should register inline bundles by default"); + assert.match(String(registerStep.if), /inputs\.defer_session_state_persistence != 'true'/); assert.match(runSource, /parseSessionBundleMode\(process\.env\.SESSION_BUNDLE_MODE\)/); + assert.match(runSource, /deferSessionStatePersistence/); assert.match( runSource, /preserveExecSession:\s*sessionPolicy === "track-only" &&\s*shouldBackupSessionBundles\(sessionBundleMode, sessionPolicy\)/, @@ -2165,11 +2350,16 @@ test("execution workflows expose automation handoff inputs", () => { assert.match(reviewWorkflow, /id: post_comment/); assert.match(reviewWorkflow, /RESPONSE_FILE:\s*\$\{\{ steps\.synthesis\.outputs\.response_file \}\}/); assert.match(reviewWorkflow, /steps\.post_comment\.outcome == 'success'/); - assert.match(orchestratorWorkflow, /PLANNER_RESPONSE_FILE:\s*\$\{\{ steps\.planner\.outputs\.response_file \}\}/); + assert.match(orchestratorWorkflow, /PLANNER_RESPONSE_FILE:\s*\$\{\{ steps\.planner_response\.outputs\.response_file \}\}/); + assert.match(orchestratorWorkflow, /pull-requests:\s*write/); assert.match(orchestratorWorkflow, /base_branch:/); assert.match(orchestratorWorkflow, /base_pr:/); assert.match(orchestratorWorkflow, /source_handoff_context:/); assert.match(orchestratorWorkflow, /AGENT_COLLAPSE_OLD_REVIEWS:\s*\$\{\{ vars\.AGENT_COLLAPSE_OLD_REVIEWS \}\}/); + assert.match(orchestratorWorkflow, /AGENT_HANDLE:\s*\$\{\{ vars\.AGENT_HANDLE \|\| '@sepo-agent' \}\}/); + assert.match(orchestratorWorkflow, /AGENT_PROGRESS_COMMENT_ID:\s*\$\{\{ needs\.progress\.outputs\.progress_comment_id \}\}/); + assert.match(orchestratorWorkflow, /AGENT_PROGRESS_FINAL_COMMENT_MODE:\s*merge/); + assert.match(orchestratorWorkflow, /MODEL_DISPLAY:\s*\$\{\{ needs\.plan\.outputs\.model_display \}\}/); assert.match(orchestratorWorkflow, /BASE_BRANCH:\s*\$\{\{ inputs\.base_branch \}\}/); assert.match(orchestratorWorkflow, /SOURCE_HANDOFF_CONTEXT:\s*\$\{\{ inputs\.source_handoff_context \}\}/); assert.match(orchestratorWorkflow, /ORCHESTRATOR_SOURCE_HANDOFF_CONTEXT:\s*\$\{\{ inputs\.source_handoff_context \}\}/); @@ -2189,6 +2379,10 @@ test("execution workflows expose automation handoff inputs", () => { assert.match(orchestrateHandoffCli, /agent-self-merge\.yml/); assert.match(handoffSource, /Task for fix-pr/); assert.match(orchestrateHandoffCli, /collapsePreviousHandoffComments/); + assert.match(orchestrateHandoffCli, /upsertPrCommentByMarker/); + assert.match(orchestrateHandoffCli, /tryMergeProgressFinalComment/); + assert.match(handoffSource, /sepo-agent-orchestrate-final/); + assert.match(handoffSource, /sepo-agent-orchestrate-stop/); assert.match(orchestrateHandoffCli, /manual orchestrate start on issue; dispatching implement/); assert.match(fixPrWorkflow, /orchestrator_context:/); assert.match(fixPrWorkflow, /ORCHESTRATOR_CONTEXT:\s*\$\{\{ inputs\.orchestrator_context \}\}/); diff --git a/.agent/src/__tests__/orchestrate-handoff-cli.test.ts b/.agent/src/__tests__/orchestrate-handoff-cli.test.ts index a7656baa..1b6edd57 100644 --- a/.agent/src/__tests__/orchestrate-handoff-cli.test.ts +++ b/.agent/src/__tests__/orchestrate-handoff-cli.test.ts @@ -33,12 +33,19 @@ function runOrchestrateHandoff(env: Record): { const dispatchPayloadPath = join(tempDir, "dispatch.json"); const plannerResponse = env.FAKE_PLANNER_RESPONSE || ""; const plannerResponseFile = join(tempDir, "planner-response.md"); + const progressStream = env.FAKE_PROGRESS_STREAM || ""; + const progressStreamFile = join(tempDir, "planner-progress.jsonl"); const runEnv = { ...env }; if (plannerResponse) { writeFileSync(plannerResponseFile, plannerResponse, "utf8"); runEnv.PLANNER_RESPONSE_FILE = plannerResponseFile; delete runEnv.FAKE_PLANNER_RESPONSE; } + if (progressStream) { + writeFileSync(progressStreamFile, progressStream, "utf8"); + runEnv.AGENT_PROGRESS_STREAM_FILE = progressStreamFile; + delete runEnv.FAKE_PROGRESS_STREAM; + } writeFileSync(outputPath, "", "utf8"); writeFileSync( @@ -114,6 +121,9 @@ if [ "\${1-}" = "api" ] && [ "\${2-}" = "graphql" ]; then *PullRequestReviewSummaryComments*) printf '{"data":{"repository":{"pullRequest":{"comments":{"nodes":%s,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}}\\n' "\${FAKE_GRAPHQL_PR_COMMENTS-[]}" ;; + *PullRequestReviewSummaries*) + printf '{"data":{"repository":{"pullRequest":{"reviews":{"nodes":%s,"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}}\\n' "\${FAKE_GRAPHQL_PR_REVIEWS-[]}" + ;; *MinimizeReviewSummary*) printf '{"data":{"minimizeComment":{"minimizedComment":{"isMinimized":true}}}}\\n' ;; @@ -125,6 +135,15 @@ if [ "\${1-}" = "api" ] && [ "\${2-}" = "graphql" ]; then exit 0 fi +if [ "\${1-}" = "api" ] && [[ "\${2-}" == repos/*/issues/comments/* ]] && [ "\${3-}" = "--jq" ] && [ "\${4-}" = ".body" ]; then + if [ "\${FAKE_PROGRESS_BODY_MODE-}" = "error" ]; then + printf 'progress comment unavailable\\n' >&2 + exit 1 + fi + printf '%s\\n' "\${FAKE_PROGRESS_BODY-}" + exit 0 +fi + if [ "\${1-}" = "api" ] && [[ "\${2-}" == repos/*/issues/* ]] && [ "\${3-}" = "--jq" ] && [ "\${4-}" = ".id" ]; then if [ "\${FAKE_ISSUE_REST_MODE-}" = "missing" ]; then printf 'issue rest lookup failed\\n' >&2 @@ -148,6 +167,14 @@ if [ "\${1-}" = "api" ] && [ "\${2-}" = "--method" ] && [ "\${3-}" = "POST" ] && fi if [ "\${1-}" = "api" ] && [ "\${2-}" = "--method" ] && [ "\${3-}" = "PATCH" ] && [[ "\${4-}" == repos/*/issues/comments/* ]]; then + if [ "\${FAKE_PROGRESS_PATCH_MODE-}" = "error" ] && [[ "\${4-}" == */"\${AGENT_PROGRESS_COMMENT_ID-}" ]]; then + printf 'progress update denied\\n' >&2 + exit 1 + fi + exit 0 +fi + +if [ "\${1-}" = "pr" ] && [ "\${2-}" = "comment" ]; then exit 0 fi @@ -966,7 +993,7 @@ test("agent orchestrate dispatches planner-selected review for PR targets", () = assert.doesNotMatch(run.ghLog, /actions\/workflows\/agent-fix-pr\.yml\/dispatches/); }); -test("agent orchestrate stops before planner handoff for closed PR targets", () => { +test("agent orchestrate overrides planner handoff for closed PR targets", () => { const run = runOrchestrateHandoff({ AUTOMATION_MODE: "agent", TARGET_KIND: "pull_request", @@ -1351,6 +1378,7 @@ test("agent parent orchestrate stop posts final comment without follow-up", () = FAKE_PLANNER_RESPONSE: JSON.stringify({ decision: "stop", reason: "All child work is complete.", + user_message: "Implementation and review are complete.", }), }); @@ -1358,18 +1386,79 @@ test("agent parent orchestrate stop posts final comment without follow-up", () = assert.equal(run.outputs.get("decision"), "stop"); assert.equal(run.outputs.get("reason"), "agent planner stop: All child work is complete."); assert.match(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/76\/comments/); - assert.match(run.ghLog, /Sepo orchestration stopped after `orchestrate` concluded `done`\./); + assert.match(run.ghLog, /Sepo orchestration finished after `orchestrate` concluded `done`\./); + assert.match(run.ghLog, /> Requested by @lolipopshock\./); + assert.match(run.ghLog, /Implementation and review are complete\./); assert.match(run.ghLog, /Source conclusion: `done`/); assert.match(run.ghLog, /Target: `issue #76`/); assert.match(run.ghLog, /Round: `2\/10`/); assert.match(run.ghLog, /Reason: agent planner stop: All child work is complete\./); assert.match(run.ghLog, /Source run ID: `parent-run-123`/); assert.match(run.ghLog, /No follow-up workflow was dispatched/); - assert.match(run.ghLog, //); + assert.match(run.ghLog, //); assert.doesNotMatch(run.ghLog, /actions\/workflows\//); assert.equal(run.dispatchPayload, null); }); +test("agent PR orchestration preserves its terminal summary when the PR closes during planning", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "requested", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "88", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "1", + FAKE_PR_STATE: "CLOSED", + FAKE_PR_BODY: "", + FAKE_PLANNER_RESPONSE: JSON.stringify({ + decision: "stop", + reason: "The requested work is complete.", + user_message: "Implementation and review completed before the pull request closed.", + }), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.equal(run.outputs.get("decision"), "stop"); + assert.equal(run.outputs.get("reason"), "pull request is closed"); + assert.match(run.ghLog, /Implementation and review completed before the pull request closed\./); + assert.match(run.ghLog, //); + assert.doesNotMatch(run.ghLog, /actions\/workflows\//); +}); + +test("merged PR resolution preserves successful terminal cleanup classification", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "agent-self-merge", + SOURCE_CONCLUSION: "merged", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "88", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "4", + FAKE_PR_STATE: "MERGED", + FAKE_PR_BODY: "", + FAKE_PLANNER_RESPONSE: JSON.stringify({ + decision: "stop", + reason: "The pull request merged successfully.", + user_message: "The full orchestration completed and the pull request merged.", + }), + FAKE_GRAPHQL_PR_COMMENTS: JSON.stringify([ + { + id: "review-comment", + body: "## AI Review Synthesis\n\n", + isMinimized: false, + author: { login: "sepo-agent-app[bot]" }, + }, + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.equal(run.outputs.get("decision"), "stop"); + assert.equal(run.outputs.get("reason"), "pull request is merged"); + assert.match(run.ghLog, /The full orchestration completed and the pull request merged\./); + assert.match(run.ghLog, /Sepo orchestration finished successfully/); + assert.match(run.ghLog, /mutation MinimizeReviewSummary/); + assert.doesNotMatch(run.ghLog, /actions\/workflows\//); +}); + test("agent parent orchestrate blocked posts planner clarification", () => { const run = runOrchestrateHandoff({ SOURCE_ACTION: "orchestrate", @@ -1400,8 +1489,8 @@ test("agent parent orchestrate blocked posts planner clarification", () => { assert.match(run.ghLog, /Clarification request: Should the next child stack on PR #112 or wait for it to merge\?/); assert.match(run.ghLog, /Reason: agent planner blocked: Need maintainer input before choosing the next child\./); assert.match(run.ghLog, /No follow-up workflow was dispatched/); - assert.match(run.ghLog, //); - assert.doesNotMatch(run.ghLog, /Sepo orchestration stopped after/); + assert.match(run.ghLog, //); + assert.doesNotMatch(run.ghLog, /Sepo orchestration finished after/); assert.doesNotMatch(run.ghLog, /actions\/workflows\//); assert.equal(run.dispatchPayload, null); }); @@ -1426,19 +1515,23 @@ test("agent parent orchestrate blocked without message posts generic stop", () = assert.equal(run.outputs.get("decision"), "stop"); assert.equal(run.outputs.get("reason"), "agent planner blocked: Context missing."); assert.match(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/76\/comments/); - assert.match(run.ghLog, /Sepo orchestration stopped after `orchestrate` concluded `done`\./); + assert.match(run.ghLog, /Sepo orchestration finished after `orchestrate` concluded `done`\./); assert.match(run.ghLog, /Reason: agent planner blocked: Context missing\./); assert.match(run.ghLog, /No follow-up workflow was dispatched/); - assert.match(run.ghLog, //); + assert.match(run.ghLog, //); assert.doesNotMatch(run.ghLog, /Sepo orchestration needs clarification before it can continue\./); assert.doesNotMatch(run.ghLog, /Clarification request:/); assert.doesNotMatch(run.ghLog, /actions\/workflows\//); assert.equal(run.dispatchPayload, null); }); -test("agent parent orchestrate stop skips matching trusted final comment", () => { +test("agent parent orchestrate stop updates the trusted final issue comment", () => { const existingStopBody = [ - "Sepo orchestration stopped after `orchestrate` concluded `done`.", + "Sepo orchestration finished after `orchestrate` concluded `done`.", + "", + "> Requested by @lolipopshock.", + "", + "Implementation and review are complete.", "", "- Source action: `orchestrate`", "- Source conclusion: `done`", @@ -1449,7 +1542,7 @@ test("agent parent orchestrate stop skips matching trusted final comment", () => "", "No follow-up workflow was dispatched. Inspect the source action status comment and workflow logs before retrying or continuing manually.", "", - "", + "", ].join("\n"); const run = runOrchestrateHandoff({ SOURCE_ACTION: "orchestrate", @@ -1470,11 +1563,13 @@ test("agent parent orchestrate stop skips matching trusted final comment", () => FAKE_PLANNER_RESPONSE: JSON.stringify({ decision: "stop", reason: "All child work is complete.", + user_message: "Implementation and review are complete.", }), }); assert.equal(run.status, 0, run.stderr || run.stdout); assert.equal(run.outputs.get("decision"), "stop"); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/existing-stop/); assert.doesNotMatch(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/76\/comments/); assert.doesNotMatch(run.ghLog, /actions\/workflows\//); assert.equal(run.dispatchPayload, null); @@ -1496,12 +1591,12 @@ test("heuristics parent orchestrate stops do not post final comments", () => { assert.equal(run.outputs.get("decision"), "stop"); assert.equal(run.outputs.get("reason"), "automation round budget exhausted"); assert.doesNotMatch(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/76\/comments/); - assert.doesNotMatch(run.ghLog, //); + assert.doesNotMatch(run.ghLog, //); assert.doesNotMatch(run.ghLog, /actions\/workflows\//); assert.equal(run.dispatchPayload, null); }); -test("agent parent orchestrate stops for pull requests do not post final comments", () => { +test("agent parent orchestrate stops for pull requests post a finalized note", () => { const run = runOrchestrateHandoff({ SOURCE_ACTION: "orchestrate", SOURCE_CONCLUSION: "done", @@ -1517,12 +1612,391 @@ test("agent parent orchestrate stops for pull requests do not post final comment assert.equal(run.status, 0, run.stderr || run.stdout); assert.equal(run.outputs.get("decision"), "stop"); assert.equal(run.outputs.get("reason"), "pull request is closed"); - assert.doesNotMatch(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/76\/comments/); - assert.doesNotMatch(run.ghLog, //); + assert.match(run.ghLog, /pr comment 76 --body/); + assert.match(run.ghLog, /Sepo orchestration finished after `orchestrate` concluded `done`\./); + assert.match(run.ghLog, /> Requested by @lolipopshock\./); + assert.match(run.ghLog, //); + assert.doesNotMatch(run.ghLog, /mutation MinimizeReviewSummary/); assert.doesNotMatch(run.ghLog, /actions\/workflows\//); assert.equal(run.dispatchPayload, null); }); +test("orchestrator finalized notes suppress bot and configured agent mentions", () => { + for (const env of [ + { REQUESTED_BY: "sepo-agent-app[bot]" }, + { REQUESTED_BY: "@custom-agent", AGENT_HANDLE: "@custom-agent" }, + ]) { + const run = runOrchestrateHandoff({ + ...env, + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "done", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "2", + FAKE_PR_STATE: "CLOSED", + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, //); + assert.doesNotMatch(run.ghLog, /Requested by @/); + } +}); + +test("orchestrator finalized PR notes update the trusted marker comment", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "done", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "2", + FAKE_PR_STATE: "CLOSED", + FAKE_ISSUE_COMMENTS_JSON: JSON.stringify([ + { + id: "existing-final", + body: "Old final note.\n\n", + created_at: "2026-07-01T00:00:00Z", + user: { login: "sepo-agent-app[bot]" }, + }, + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/existing-final/); + assert.match(run.ghLog, /Sepo orchestration finished after/); + assert.doesNotMatch(run.ghLog, /pr comment 76 --body/); +}); + +test("orchestrator finalized PR notes migrate a trusted legacy stop marker", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "done", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "2", + FAKE_PR_STATE: "CLOSED", + FAKE_ISSUE_COMMENTS_JSON: JSON.stringify([ + { + id: "legacy-final", + body: "Old stop note.\n\n", + created_at: "2026-07-01T00:00:00Z", + user: { login: "sepo-agent-app[bot]" }, + }, + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/legacy-final/); + assert.match(run.ghLog, //); + assert.doesNotMatch(run.ghLog, /pr comment 76 --body/); +}); + +test("orchestrator finalized PR notes reuse a live planner progress comment", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "done", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "2", + FAKE_PR_STATE: "CLOSED", + AGENT_PROGRESS_COMMENT_ID: "progress-comment", + AGENT_PROGRESS_FINAL_COMMENT_MODE: "merge", + MODEL_DISPLAY: "`codex` | `test-model`", + FAKE_PROGRESS_BODY: [ + "### Sepo is working…", + "", + "Latest activity", + "", + "", + ].join("\n"), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /api repos\/self-evolving\/repo\/issues\/comments\/progress-comment --jq \.body/); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/progress-comment/); + assert.match(run.ghLog, /Sepo activity<\/summary>/); + assert.match(run.ghLog, //); + assert.match(run.ghLog, /`codex` \| `test-model`/); + assert.doesNotMatch(run.ghLog, /pr comment 76 --body/); +}); + +test("planner progress is finalized when orchestration dispatches", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "requested", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "1", + FAKE_PR_STATE: "OPEN", + AGENT_PROGRESS_COMMENT_ID: "planner-progress", + AGENT_PROGRESS_FINAL_COMMENT_MODE: "merge", + GITHUB_RUN_ID: "12345", + FAKE_PROGRESS_STREAM: [ + JSON.stringify({ + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Selecting the next route." }, + }, + }, + }), + ].join("\n"), + FAKE_PLANNER_RESPONSE: JSON.stringify({ + decision: "handoff", + next_action: "review", + reason: "Review the open pull request.", + }), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.equal(run.outputs.get("decision"), "dispatch"); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/planner-progress/); + assert.match(run.ghLog, /### Sepo finished — orchestrator · 1 step\n/); + assert.doesNotMatch(run.ghLog, /orchestrator · 0s/); + assert.match(run.ghLog, /Selecting the next route\./); + assert.match(run.ghLog, //); + assert.match(run.ghLog, /actions\/workflows\/agent-review\.yml\/dispatches/); +}); + +test("planner progress finalization failures are visible without blocking dispatch", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "requested", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "1", + FAKE_PR_STATE: "OPEN", + AGENT_PROGRESS_COMMENT_ID: "planner-progress", + AGENT_PROGRESS_FINAL_COMMENT_MODE: "merge", + FAKE_PROGRESS_PATCH_MODE: "error", + FAKE_PLANNER_RESPONSE: JSON.stringify({ + decision: "handoff", + next_action: "review", + reason: "Review the open pull request.", + }), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.stderr, /Failed to finalize progress comment planner-progress/); + assert.match(run.stderr, /progress update denied/); + assert.match(run.ghLog, /actions\/workflows\/agent-review\.yml\/dispatches/); +}); + +test("a current planner progress note supersedes an older finalized marker", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "done", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "3", + FAKE_PR_STATE: "CLOSED", + AGENT_PROGRESS_COMMENT_ID: "current-progress", + AGENT_PROGRESS_FINAL_COMMENT_MODE: "merge", + FAKE_PROGRESS_BODY: "### Sepo is working…\n\n", + FAKE_ISSUE_COMMENTS_JSON: JSON.stringify([ + { + id: "previous-final", + body: "Old final note.\n\n", + created_at: "2026-07-01T00:00:00Z", + user: { login: "sepo-agent-app[bot]" }, + }, + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/current-progress/); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/previous-final/); + assert.match(run.ghLog, /The latest finalized orchestration note is in the current run's progress comment\./); + assert.doesNotMatch(run.ghLog, /pr comment 76 --body/); +}); + +test("planner progress merge failure falls back to the finalized marker upsert", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "orchestrate", + SOURCE_CONCLUSION: "done", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "76", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "2", + FAKE_PR_STATE: "CLOSED", + AGENT_PROGRESS_COMMENT_ID: "missing-progress", + AGENT_PROGRESS_FINAL_COMMENT_MODE: "merge", + FAKE_PROGRESS_BODY_MODE: "error", + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.stderr, /Failed to merge final response into progress comment missing-progress/); + assert.match(run.ghLog, /pr comment 76 --body/); + assert.match(run.ghLog, //); +}); + +test("successful terminal PR orchestration collapses prior trusted review artifacts", () => { + const generated = (id: string, body: string) => ({ + id, + body, + isMinimized: false, + author: { login: "sepo-agent-app[bot]" }, + }); + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "review", + SOURCE_CONCLUSION: "SHIP", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "88", + AUTOMATION_MODE: "heuristics", + AUTOMATION_CURRENT_ROUND: "4", + FAKE_PR_BODY: "", + FAKE_GRAPHQL_PR_COMMENTS: JSON.stringify([ + generated("review-comment", "## AI Review Synthesis\n\n"), + generated("rubrics-comment", "## Rubrics Review\n\nPASS"), + generated("fix-comment", ""), + generated("handoff-comment", ""), + ]), + FAKE_GRAPHQL_PR_REVIEWS: JSON.stringify([ + generated("review-node", "## AI Review Synthesis\n\n"), + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /Sepo orchestration finished successfully after `review` concluded `SHIP`\./); + assert.match(run.ghLog, /No further workflow was needed\./); + assert.equal((run.ghLog.match(/mutation MinimizeReviewSummary/g) || []).length, 5); + assert.doesNotMatch(run.ghLog, /actions\/workflows\//); +}); + +test("blocked planner decisions retain successful-source review artifacts", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "review", + SOURCE_CONCLUSION: "SHIP", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "88", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "4", + FAKE_PR_BODY: "", + FAKE_PLANNER_RESPONSE: JSON.stringify({ + decision: "blocked", + reason: "A maintainer must choose the release target.", + clarification_request: "Which release target should this use?", + }), + FAKE_GRAPHQL_PR_COMMENTS: JSON.stringify([ + { + id: "review-comment", + body: "## AI Review Synthesis\n\n", + isMinimized: false, + author: { login: "sepo-agent-app[bot]" }, + }, + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /Sepo orchestration needs clarification before it can continue\./); + assert.doesNotMatch(run.ghLog, /mutation MinimizeReviewSummary/); +}); + +for (const [label, plannerResponse] of [ + ["missing", undefined], + ["malformed", "not valid planner json"], +] as const) { + test(`${label} planner responses cannot produce successful terminal cleanup`, () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "review", + SOURCE_CONCLUSION: "SHIP", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "88", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "4", + FAKE_PR_BODY: "", + FAKE_PLANNER_RESPONSE: plannerResponse, + FAKE_GRAPHQL_PR_COMMENTS: JSON.stringify([ + { + id: "review-comment", + body: "## AI Review Synthesis\n\n", + isMinimized: false, + author: { login: "sepo-agent-app[bot]" }, + }, + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.outputs.get("reason") || "", /agent planner decision missing or invalid/); + assert.match(run.ghLog, /Sepo orchestration finished after `review` concluded `SHIP`\./); + assert.match(run.ghLog, /No follow-up workflow was dispatched\./); + assert.doesNotMatch(run.ghLog, /finished successfully/); + assert.doesNotMatch(run.ghLog, /No further workflow was needed\./); + assert.doesNotMatch(run.ghLog, /mutation MinimizeReviewSummary/); + }); +} + +test("successful cleanup never minimizes the newly finalized note", () => { + const generated = (id: string, body: string) => ({ + id, + body, + isMinimized: false, + author: { login: "sepo-agent-app[bot]" }, + }); + const finalNoteWithArtifactSignatures = [ + "## AI Review Synthesis", + "## Rubrics Review", + "", + "", + "", + ].join("\n\n"); + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "review", + SOURCE_CONCLUSION: "SHIP", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "88", + AUTOMATION_MODE: "agent", + AUTOMATION_CURRENT_ROUND: "4", + FAKE_PR_BODY: "", + FAKE_PLANNER_RESPONSE: JSON.stringify({ + decision: "stop", + reason: "The pull request is ready.", + user_message: "## Rubrics Review\n\nAll requested work is complete.", + }), + FAKE_GRAPHQL_PR_COMMENTS: JSON.stringify([ + generated("final-note", finalNoteWithArtifactSignatures), + generated("old-rubrics", "## Rubrics Review\n\nOld scorecard"), + ]), + FAKE_GRAPHQL_PR_REVIEWS: "[]", + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.equal((run.ghLog.match(/mutation MinimizeReviewSummary/g) || []).length, 1); + assert.match(run.ghLog, /id=old-rubrics/); + assert.doesNotMatch(run.ghLog, /id=final-note/); +}); + +test("successful terminal PR orchestration respects disabled review cleanup", () => { + const run = runOrchestrateHandoff({ + SOURCE_ACTION: "review", + SOURCE_CONCLUSION: "SHIP", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "88", + AUTOMATION_MODE: "heuristics", + AUTOMATION_CURRENT_ROUND: "4", + AGENT_COLLAPSE_OLD_REVIEWS: "false", + FAKE_PR_BODY: "", + FAKE_GRAPHQL_PR_COMMENTS: JSON.stringify([ + { + id: "review-comment", + body: "## AI Review Synthesis\n\n", + isMinimized: false, + author: { login: "sepo-agent-app[bot]" }, + }, + ]), + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, //); + assert.doesNotMatch(run.ghLog, /mutation MinimizeReviewSummary/); +}); + test("terminal child result reports to parent and preserves terminal reruns", () => { const childBody = ""; const run = runOrchestrateHandoff({ @@ -1545,7 +2019,7 @@ test("terminal child result reports to parent and preserves terminal reruns", () assert.match(run.ghLog, /\| #77 \| #88 \| Ready to ship \| 2 \/ 5 \| Resuming parent orchestration \|/); assert.match(run.ghLog, /Summary: review verdict is SHIP/); assert.match(run.ghLog, //); - assert.doesNotMatch(run.ghLog, //); + assert.doesNotMatch(run.ghLog, //); const inputs = run.dispatchPayload?.inputs as Record; assert.equal(inputs.source_action, "orchestrate"); assert.equal(inputs.source_conclusion, "done"); @@ -1665,7 +2139,7 @@ test("terminal child rejected-marker stop comments are deduped on rerun", () => assert.match(run.stderr, /Ignoring untrusted terminal sub-orchestrator marker in issue #77 body from lolipopshock/); }); -test("ordinary terminal PR stops skip visible sub-orchestration stop without child marker", () => { +test("ordinary terminal PR stops post a final note without a sub-orchestration marker", () => { const run = runOrchestrateHandoff({ SOURCE_ACTION: "review", SOURCE_CONCLUSION: "SHIP", @@ -1680,7 +2154,8 @@ test("ordinary terminal PR stops skip visible sub-orchestration stop without chi assert.equal(run.status, 0); assert.equal(run.outputs.get("decision"), "stop"); - assert.doesNotMatch(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/88\/comments/); + assert.match(run.ghLog, /pr comment 88 --body/); + assert.match(run.ghLog, //); assert.doesNotMatch(run.ghLog, /sepo-sub-orchestrator-terminal-stop/); assert.doesNotMatch(run.ghLog, /actions\/workflows\/agent-orchestrator\.yml\/dispatches/); assert.doesNotMatch(run.stderr, /Ignoring untrusted terminal sub-orchestrator marker/); diff --git a/.agent/src/__tests__/progress-render.test.ts b/.agent/src/__tests__/progress-render.test.ts index 5fe4b790..7f35736c 100644 --- a/.agent/src/__tests__/progress-render.test.ts +++ b/.agent/src/__tests__/progress-render.test.ts @@ -96,6 +96,19 @@ test("renders empty running progress as starting with marker", () => { assert.match(body, //); }); +test("omits elapsed metadata when the duration is unknown", () => { + const model = buildProgressViewModel(messageEvent("Done."), { + runId: "cross-job", + route: "orchestrator", + status: "finalized", + }); + + assert.equal( + renderFinal(model, "finished").split("\n", 1)[0], + "### Sepo finished — orchestrator · 1 step", + ); +}); + test("derives friendly running progress from tools and messages", () => { const tail = [ toolEvent("Read"), diff --git a/.agent/src/__tests__/publish-orchestration-progress-cli.test.ts b/.agent/src/__tests__/publish-orchestration-progress-cli.test.ts new file mode 100644 index 00000000..80dea8cf --- /dev/null +++ b/.agent/src/__tests__/publish-orchestration-progress-cli.test.ts @@ -0,0 +1,156 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { strict as assert } from "node:assert"; + +const repoRoot = resolve(__dirname, "../../.."); + +function runPublisher(options: { + policy: string; + ghExit?: number; + comments?: Array>; +}): { + status: number | null; + stderr: string; + stdout: string; + ghLog: string; + output: string; +} { + const tempDir = mkdtempSync(join(tmpdir(), "agent-orchestration-progress-")); + try { + const fakeGh = join(tempDir, "gh"); + const ghLog = join(tempDir, "gh.log"); + const output = join(tempDir, "github-output.txt"); + writeFileSync(output, "", "utf8"); + writeFileSync( + fakeGh, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$FAKE_GH_LOG" +if [ "\${1-}" = "api" ] && [ "\${2-}" = "graphql" ]; then + printf '{"data":{"viewer":{"login":"sepo-agent-app[bot]"}}}\\n' + exit 0 +fi +if [ "\${1-}" = "api" ] && [ "\${2-}" = "--paginate" ] && [ "\${3-}" = "--slurp" ]; then + printf '%s\\n' "$FAKE_ISSUE_COMMENTS_JSON" + exit 0 +fi +if [ "\${1-}" = "api" ] && [ "\${2-}" = "--method" ] && { [ "\${3-}" = "POST" ] || [ "\${3-}" = "PATCH" ]; }; then + if [ "${options.ghExit || 0}" -ne 0 ]; then + printf 'comment publication denied\\n' >&2 + exit ${options.ghExit || 0} + fi + if [ "\${3-}" = "POST" ]; then + printf '4242\\n' + fi + exit 0 +fi +printf 'unexpected gh args: %s\\n' "$*" >&2 +exit 1 +`, + { encoding: "utf8", mode: 0o755 }, + ); + + const result = spawnSync( + "node", + [".agent/dist/cli/publish-orchestration-progress.js"], + { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH || ""}`, + AGENT_PROGRESS_POLICY: options.policy, + FAKE_GH_LOG: ghLog, + FAKE_ISSUE_COMMENTS_JSON: JSON.stringify([options.comments || []]), + GH_TOKEN: "fake-token", + GITHUB_OUTPUT: output, + GITHUB_REPOSITORY: "self-evolving/repo", + GITHUB_RUN_ID: "12345", + ROUTE: "orchestrator", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "495", + }, + encoding: "utf8", + }, + ); + + return { + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + ghLog: existsSync(ghLog) ? readFileSync(ghLog, "utf8") : "", + output: readFileSync(output, "utf8"), + }; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +test("report-only orchestration progress publishes through the trusted CLI", () => { + const run = runPublisher({ + policy: '{"orchestration_mode":"report-only"}', + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/495\/comments/); + assert.match(run.ghLog, /Sepo is working/); + assert.match(run.ghLog, //); + assert.match(run.output, /progress_comment_id< { + const run = runPublisher({ + policy: '{"orchestration_mode":"report-only"}', + comments: [ + { + id: 7171, + body: "Earlier attempt.\n\n", + created_at: "2026-08-10T19:00:00Z", + user: { login: "sepo-agent-app[bot]" }, + }, + { + id: 8181, + body: "Prior attempt.\n\n", + created_at: "2026-08-10T20:00:00Z", + user: { login: "sepo-agent-app[bot]" }, + }, + { + id: 9191, + body: "Forged marker.\n\n", + created_at: "2026-08-10T21:00:00Z", + user: { login: "someone-else" }, + }, + ], + }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.match(run.ghLog, /api --method PATCH repos\/self-evolving\/repo\/issues\/comments\/8181/); + assert.doesNotMatch(run.ghLog, /issues\/comments\/(?:7171|9191)/); + assert.doesNotMatch(run.ghLog, /api --method POST repos\/self-evolving\/repo\/issues\/495\/comments/); + assert.match(run.output, /8181/); + assert.match(run.stdout, /Updated orchestration progress comment 8181\./); +}); + +test("disabled orchestration progress performs no publication", () => { + const run = runPublisher({ policy: "" }); + + assert.equal(run.status, 0, run.stderr || run.stdout); + assert.equal(run.ghLog, ""); + assert.equal(run.output, ""); + assert.match(run.stdout, /orchestration progress skipped: mode=disabled/); +}); + +test("orchestration progress publication failures are visible", () => { + const run = runPublisher({ + policy: '{"orchestration_mode":"report-only"}', + ghExit: 1, + }); + + assert.equal(run.status, 1); + assert.match(run.stderr, /Failed to publish orchestration progress:/); + assert.match(run.stderr, /comment publication denied/); + assert.equal(run.output, ""); +}); diff --git a/.agent/src/__tests__/session-state-persistence.test.ts b/.agent/src/__tests__/session-state-persistence.test.ts new file mode 100644 index 00000000..55a500ba --- /dev/null +++ b/.agent/src/__tests__/session-state-persistence.test.ts @@ -0,0 +1,187 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { test } from "node:test"; +import { strict as assert } from "node:assert"; + +import { persistSessionRunState } from "../session-state-persistence.js"; +import { createSessionBundle } from "../session-bundle.js"; +import { + fetchThreadState, + markThreadBundleStored, + refPathForThreadKey, +} from "../thread-state.js"; + +const THREAD_KEY = "self-evolving/repo:pull_request:495:orchestrator:planner"; + +function gitIn(dir: string, args: string[]): string { + return execFileSync("git", args, { + cwd: dir, + stdio: ["pipe", "pipe", "pipe"], + }).toString("utf8").trim(); +} + +function configureTestRepo(dir: string): void { + gitIn(dir, ["config", "user.name", "test"]); + gitIn(dir, ["config", "user.email", "test@test.com"]); +} + +test("deferred planner state and bundle metadata survive onto a new runner", () => { + const root = mkdtempSync(join(tmpdir(), "planner-session-state-")); + const remote = join(root, "remote.git"); + const firstRunner = join(root, "round-1"); + const secondRunner = join(root, "round-2"); + const sourceHome = join(root, "source-home"); + const restoredHome = join(root, "restored-home"); + const bundleTemp = join(root, "bundle-temp"); + const fakeBin = join(root, "bin"); + const githubOutput = join(root, "github-output"); + + try { + execFileSync("git", ["init", "--bare", remote], { stdio: "pipe" }); + execFileSync("git", ["clone", remote, firstRunner], { stdio: "pipe" }); + configureTestRepo(firstRunner); + + const first = persistSessionRunState({ + repoRoot: firstRunner, + repoSlug: "self-evolving/repo", + route: "orchestrator", + targetKind: "pull_request", + targetNumber: 495, + lane: "planner", + expectedThreadKey: THREAD_KEY, + exitCode: 0, + acpxRecordId: "record-round-1", + acpxSessionId: "session-round-1", + resumeStatus: "not_attempted", + bundleRestoreStatus: "not_available", + lastRunUrl: "https://github.com/self-evolving/repo/actions/runs/1", + }); + assert.equal(first.status, "completed"); + assert.equal(first.attempt_count, 1); + + mkdirSync(join(sourceHome, ".acpx", "sessions"), { recursive: true }); + mkdirSync(join(sourceHome, ".codex", "sessions", "2026", "08", "10"), { + recursive: true, + }); + mkdirSync(bundleTemp, { recursive: true }); + writeFileSync( + join(sourceHome, ".acpx", "sessions", "record-round-1.json"), + '{"session":"round-1"}\n', + ); + writeFileSync( + join(sourceHome, ".codex", "sessions", "2026", "08", "10", "session-round-1.jsonl"), + "round-1\n", + ); + const bundle = createSessionBundle({ + agent: "codex", + threadKey: THREAD_KEY, + repoSlug: "self-evolving/repo", + cwd: firstRunner, + acpxRecordId: "record-round-1", + acpxSessionId: "session-round-1", + homeDir: sourceHome, + runnerTemp: bundleTemp, + }); + assert.ok(bundle); + + markThreadBundleStored( + THREAD_KEY, + firstRunner, + { + session_bundle_backend: "github-artifact", + session_bundle_artifact_id: "101", + session_bundle_artifact_name: "agent-session-round-1", + session_bundle_run_id: "1", + }, + ); + + execFileSync("git", ["clone", remote, secondRunner], { stdio: "pipe" }); + configureTestRepo(secondRunner); + const restored = fetchThreadState(THREAD_KEY, secondRunner); + assert.ok(restored); + assert.equal(restored.acpxSessionId, "session-round-1"); + assert.equal(restored.session_bundle_artifact_name, "agent-session-round-1"); + + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(restoredHome, { recursive: true }); + writeFileSync(githubOutput, "", "utf8"); + const fakeGh = join(fakeBin, "gh"); + writeFileSync( + fakeGh, + `#!/usr/bin/env node\nconst { copyFileSync } = require("node:fs");\nconst { join } = require("node:path");\nconst args = process.argv.slice(2);\nconst destination = args[args.indexOf("-D") + 1];\ncopyFileSync(${JSON.stringify(bundle!.bundlePath)}, join(destination, "session.tgz"));\n`, + "utf8", + ); + chmodSync(fakeGh, 0o755); + const stateRef = refPathForThreadKey(THREAD_KEY); + const stateBeforeRestore = gitIn(remote, ["rev-parse", stateRef]); + const restore = spawnSync( + process.execPath, + [join(__dirname, "..", "cli", "session-restore.js")], + { + cwd: secondRunner, + env: { + ...process.env, + PATH: `${fakeBin}${delimiter}${process.env.PATH || ""}`, + DEFER_SESSION_STATE_PERSISTENCE: "true", + GH_TOKEN: "", + GITHUB_OUTPUT: githubOutput, + GITHUB_REPOSITORY: "self-evolving/repo", + GITHUB_TOKEN: "", + GITHUB_WORKSPACE: secondRunner, + HOME: restoredHome, + INPUT_GITHUB_TOKEN: "", + LANE: "planner", + ROUTE: "orchestrator", + RUNNER_TEMP: root, + SESSION_BUNDLE_MODE: "auto", + SESSION_POLICY: "resume-best-effort", + TARGET_KIND: "pull_request", + TARGET_NUMBER: "495", + }, + encoding: "utf8", + }, + ); + assert.equal(restore.status, 0, restore.stderr || restore.stdout); + assert.match(readFileSync(githubOutput, "utf8"), /restore_status<<[^\n]+\nrestored\n/); + assert.equal( + readFileSync(join(restoredHome, ".acpx", "sessions", "record-round-1.json"), "utf8"), + '{"session":"round-1"}\n', + ); + assert.equal(gitIn(remote, ["rev-parse", stateRef]), stateBeforeRestore); + + const second = persistSessionRunState({ + repoRoot: secondRunner, + repoSlug: "self-evolving/repo", + route: "orchestrator", + targetKind: "pull_request", + targetNumber: 495, + lane: "planner", + expectedThreadKey: THREAD_KEY, + exitCode: 0, + acpxRecordId: "record-round-2", + acpxSessionId: "session-round-2", + resumeStatus: "resumed", + resumedFromSessionId: "session-round-1", + bundleRestoreStatus: "restored", + lastRunUrl: "https://github.com/self-evolving/repo/actions/runs/2", + }); + + assert.equal(second.status, "completed"); + assert.equal(second.attempt_count, 2); + assert.equal(second.acpxSessionId, "session-round-2"); + assert.equal(second.resumed_from_session_id, "session-round-1"); + assert.equal(second.bundle_restore_status, "restored"); + assert.equal(second.session_bundle_artifact_name, "agent-session-round-1"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/.agent/src/cli/orchestrate-handoff.ts b/.agent/src/cli/orchestrate-handoff.ts index c9cd0d79..930fdd58 100644 --- a/.agent/src/cli/orchestrate-handoff.ts +++ b/.agent/src/cli/orchestrate-handoff.ts @@ -4,12 +4,19 @@ // GITHUB_REPOSITORY, DEFAULT_BRANCH, REQUESTED_BY, REQUEST_TEXT, // SESSION_BUNDLE_MODE, SOURCE_RUN_ID, PLANNER_RESPONSE_FILE, TARGET_KIND, // BASE_BRANCH, BASE_PR, AGENT_COLLAPSE_OLD_REVIEWS, AGENT_ALLOW_SELF_APPROVE, -// AGENT_ALLOW_SELF_MERGE +// AGENT_ALLOW_SELF_MERGE, AGENT_HANDLE, AGENT_PROGRESS_COMMENT_ID, +// AGENT_PROGRESS_FINAL_COMMENT_MODE, AGENT_PROGRESS_STREAM_FILE, MODEL_DISPLAY import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createIssueComment, dispatchWorkflow, gh, updateIssueComment } from "../github.js"; +import { + createIssueComment, + dispatchWorkflow, + gh, + updateIssueComment, + upsertPrCommentByMarker, +} from "../github.js"; import { setOutput } from "../output.js"; import { type HandoffDecision, @@ -19,13 +26,25 @@ import { defaultFixPrHandoffContext, formatHandoffMarkerComment, formatTransposedMarkdownTable, + hasAnyOrchestrateFinalMarker, isPendingHandoffMarkerStale, + ORCHESTRATE_FINAL_MARKER, normalizeAutomationMode, parsePlannerDecision, parseHandoffMarker, } from "../handoff.js"; import { initialOrchestrateCapabilityStopReason } from "../orchestrator-capabilities.js"; -import { collapsePreviousHandoffComments } from "../review-summary-minimize.js"; +import { + tryFinalizeProgressActivity, + tryMergeProgressFinalComment, +} from "../progress-final-comment.js"; +import { + collapsePreviousFixPrComments, + collapsePreviousHandoffComments, + collapsePreviousReviewSummaries, + collapsePreviousRubricsReviews, +} from "../review-summary-minimize.js"; +import { appendRunDisplayFooter } from "../response.js"; import { extractClosingIssueNumber, formatSubOrchestrationIssueBody, @@ -85,7 +104,6 @@ type TerminalChildResolution = | { kind: "none" }; const SUB_ORCHESTRATION_ADOPTION_COMMENT_MARKER = ""; -const ORCHESTRATE_STOP_MARKER = ""; const TERMINAL_SUB_ORCHESTRATION_STOP_MARKER_PREFIX = "sepo-sub-orchestrator-terminal-stop"; const PENDING_MARKER_TTL_MS = 60 * 60 * 1000; const UNSATISFACTORY_ACTION_CONCLUSIONS = new Set(["no_changes", "failed", "verify_failed", "unsupported"]); @@ -841,6 +859,11 @@ const isPublicRepo = String(process.env.REPOSITORY_PRIVATE || "").trim().toLower const targetNumber = process.env.TARGET_NUMBER || ""; const requestedBy = process.env.REQUESTED_BY || ""; const requestText = process.env.REQUEST_TEXT || ""; +const agentHandle = process.env.AGENT_HANDLE || "@sepo-agent"; +const modelDisplay = process.env.MODEL_DISPLAY || process.env.AGENT_RUN_DISPLAY || ""; +const progressFinalCommentMode = process.env.AGENT_PROGRESS_FINAL_COMMENT_MODE || ""; +const progressCommentId = process.env.AGENT_PROGRESS_COMMENT_ID || process.env.PROGRESS_COMMENT_ID || ""; +const progressStreamFile = process.env.AGENT_PROGRESS_STREAM_FILE || process.env.PROGRESS_STREAM_FILE || ""; const sessionBundleMode = process.env.SESSION_BUNDLE_MODE || ""; const baseBranch = process.env.BASE_BRANCH || ""; const basePr = process.env.BASE_PR || ""; @@ -888,6 +911,25 @@ function readPlannerDecision(): ReturnType { } } +function finalizeOrchestrationProgress(): void { + let streamText = ""; + if (progressStreamFile) { + try { + streamText = readFileSync(progressStreamFile, "utf8"); + } catch (err: unknown) { + console.warn(`Failed to read orchestration progress activity: ${errorText(err)}`); + } + } + tryFinalizeProgressActivity({ + repo, + commentId: progressCommentId, + mode: progressFinalCommentMode, + streamText, + runId: process.env.GITHUB_RUN_ID || sourceRunId || "unknown", + route: "orchestrator", + }); +} + function normalizeToken(value: string): string { return String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_"); } @@ -968,16 +1010,16 @@ function commentOnTerminalSubOrchestrationRejection(rejection: TerminalSubOrches })); } -function reportTerminalToParent(decision: HandoffDecision): void { +function reportTerminalToParent(decision: HandoffDecision): boolean { const childResolution = resolveChildIssueForTerminal(); - if (childResolution.kind === "none") return; + if (childResolution.kind === "none") return false; if (childResolution.kind === "rejected") { commentOnTerminalSubOrchestrationRejection(childResolution.rejection); - return; + return true; } const childIssue = childResolution.issue; const marker = childIssue.subOrchestrator.marker; - if (!["running", "done", "blocked", "failed"].includes(marker.state)) return; + if (!["running", "done", "blocked", "failed"].includes(marker.state)) return true; const resultState = marker.state === "running" ? resultStateFromTerminal({ sourceAction, @@ -996,7 +1038,7 @@ function reportTerminalToParent(decision: HandoffDecision): void { const existingProgress = progressComments[progressComments.length - 1]; const progressWasDispatched = String(existingProgress?.body || "").includes(dispatchedProgressMarker); if (marker.state !== "running" && progressWasDispatched) { - return; + return true; } let progressCommentId = existingProgress?.id ? String(existingProgress.id) : ""; const writeProgress = (progressMarker: string): void => { @@ -1046,6 +1088,7 @@ function reportTerminalToParent(decision: HandoffDecision): void { if (updatedChildMarkerBody !== childIssue.subOrchestrator.body) { updateTrustedSubOrchestratorMarker(repo, childIssue, updatedChildMarkerBody); } + return true; } function pushUniqueMarkdownBlock(lines: string[], value: string | undefined): void { @@ -1054,6 +1097,49 @@ function pushUniqueMarkdownBlock(lines: string[], value: string | undefined): vo lines.push(text); } +function requestedByHumanLine(): string { + const raw = requestedBy.trim(); + if (!raw || /^app\//i.test(raw) || /\[bot\]$/i.test(raw)) return ""; + + const login = raw.replace(/^@+/, ""); + if (!/^[a-z\d](?:[a-z\d-]{0,38})$/i.test(login)) return ""; + + const normalizedLogin = normalizeActorLogin(login); + const normalizedHandle = normalizeActorLogin(agentHandle.replace(/^@+/, "")); + if ( + normalizedLogin === normalizedHandle || + normalizedLogin === "sepo-agent" || + normalizedLogin === "sepo-agent-app" || + normalizedLogin === "github-actions" + ) { + return ""; + } + return `> Requested by @${login}.`; +} + +function startFinalComment(heading: string): string[] { + const lines = [heading]; + const requester = requestedByHumanLine(); + if (requester) lines.push("", requester); + return lines; +} + +function isSuccessfulPrTerminalState(decision: HandoffDecision): boolean { + const action = normalizeToken(sourceAction); + const conclusion = normalizeToken(sourceConclusion); + const successfulSource = ( + (action === "review" && conclusion === "ship") || + (action === "agent_self_approve" && conclusion === "approved") || + (action === "agent_self_merge" && ["merged", "auto_merge_enabled"].includes(conclusion)) + ); + return successfulSource && decision.decision === "stop" && ( + automationMode !== "agent" || ( + decision.plannerDecisionKind === "stop" && + !String(decision.clarificationRequest || "").trim() + ) + ); +} + function formatPlannerClarificationComment(decision: HandoffDecision): string | null { if (decision.plannerDecisionKind !== "blocked") { return null; @@ -1068,8 +1154,8 @@ function formatPlannerClarificationComment(decision: HandoffDecision): string | return null; } - const lines = [ - "Sepo orchestration needs clarification before it can continue.", + const lines = startFinalComment("Sepo orchestration needs clarification before it can continue."); + lines.push( "", ...messageLines.flatMap((message, index) => index === 0 ? [message] : ["", message]), "", @@ -1078,7 +1164,7 @@ function formatPlannerClarificationComment(decision: HandoffDecision): string | `- Target: \`${sourceTargetKind || "unknown"} #${targetNumber || "unknown"}\``, `- Round: \`${currentRound}/${maxRounds}\``, `- Reason: ${decision.reason}`, - ]; + ); if (sourceRunId) { lines.push(`- Source run ID: \`${sourceRunId}\``); @@ -1088,7 +1174,7 @@ function formatPlannerClarificationComment(decision: HandoffDecision): string | "", "No follow-up workflow was dispatched. Reply with the requested context, then continue with `/orchestrate`, `/implement`, or `/answer` when ready.", "", - ORCHESTRATE_STOP_MARKER, + ORCHESTRATE_FINAL_MARKER, ); return lines.join("\n"); } @@ -1101,8 +1187,8 @@ function formatPlannerAnswerComment(decision: HandoffDecision): string | null { const message = String(decision.userMessage || "").trim(); if (!message) return null; - const lines = [ - "Sepo answered this orchestration request.", + const lines = startFinalComment("Sepo answered this orchestration request."); + lines.push( "", message, "", @@ -1111,13 +1197,13 @@ function formatPlannerAnswerComment(decision: HandoffDecision): string | null { `- Target: \`${sourceTargetKind || "unknown"} #${targetNumber || "unknown"}\``, `- Round: \`${currentRound}/${maxRounds}\``, `- Reason: ${decision.reason}`, - ]; + ); if (sourceRunId) { lines.push(`- Source run ID: \`${sourceRunId}\``); } - lines.push("", ORCHESTRATE_STOP_MARKER); + lines.push("", ORCHESTRATE_FINAL_MARKER); return lines.join("\n"); } @@ -1131,15 +1217,21 @@ function formatOrchestrateStopComment(decision: HandoffDecision): string { return answerComment; } - const lines = [ - `Sepo orchestration stopped after \`${sourceAction || "unknown"}\` concluded \`${sourceConclusion || "unknown"}\`.`, + const successful = isSuccessfulPrTerminalState(decision); + const lines = startFinalComment( + `Sepo orchestration finished${successful ? " successfully" : ""} after ` + + `\`${sourceAction || "unknown"}\` concluded \`${sourceConclusion || "unknown"}\`.`, + ); + const userMessage = String(decision.userMessage || "").trim(); + if (userMessage) lines.push("", userMessage); + lines.push( "", `- Source action: \`${sourceAction || "unknown"}\``, `- Source conclusion: \`${sourceConclusion || "unknown"}\``, `- Target: \`${sourceTargetKind || "unknown"} #${targetNumber || "unknown"}\``, `- Round: \`${currentRound}/${maxRounds}\``, `- Reason: ${decision.reason}`, - ]; + ); if (sourceRunId) { lines.push(`- Source run ID: \`${sourceRunId}\``); @@ -1147,40 +1239,120 @@ function formatOrchestrateStopComment(decision: HandoffDecision): string { lines.push( "", - "No follow-up workflow was dispatched. Inspect the source action status comment and workflow logs before retrying or continuing manually.", + successful + ? "No further workflow was needed." + : "No follow-up workflow was dispatched. Inspect the source action status comment and workflow logs before retrying or continuing manually.", "", - ORCHESTRATE_STOP_MARKER, + ORCHESTRATE_FINAL_MARKER, ); return lines.join("\n"); } -function hasMatchingOrchestrateStopComment(repoSlug: string, issueNumber: number, body: string): boolean { +function findTrustedOrchestrateFinalComment(repoSlug: string, target: number): CommentRecord | undefined { try { - const expectedBody = body.trim(); - return fetchIssueComments(repoSlug, issueNumber).some((comment) => { - const commentBody = String(comment.body || ""); - return ( - commentBody.includes(ORCHESTRATE_STOP_MARKER) && - commentBody.trim() === expectedBody && - isTrustedActorLogin(comment.authorLogin || "") - ); - }); + return [...fetchIssueComments(repoSlug, target)].reverse().find((comment) => ( + Boolean(comment.id) && + hasAnyOrchestrateFinalMarker(String(comment.body || "")) && + isTrustedActorLogin(comment.authorLogin || "") + )); } catch (err: unknown) { - console.warn(`Failed to inspect existing orchestrator stop comments: ${errorText(err)}`); - return false; + console.warn(`Failed to inspect existing orchestrator final comments: ${errorText(err)}`); + return undefined; + } +} + +function collapseSuccessfulPrArtifacts(prNumber: number, decision: HandoffDecision): void { + if ( + !collapseOldReviews || + !isSuccessfulPrTerminalState(decision) || + decision.plannerDecisionKind === "blocked" || + Boolean(String(decision.clarificationRequest || "").trim()) + ) { + return; + } + + const cleanupTasks: Array<[string, () => number]> = [ + ["AI review synthesis", () => collapsePreviousReviewSummaries({ repo, prNumber })], + ["rubrics review", () => collapsePreviousRubricsReviews({ repo, prNumber })], + ["fix-pr status", () => collapsePreviousFixPrComments({ repo, prNumber })], + ["orchestrator handoff", () => collapsePreviousHandoffComments({ + repo, + targetNumber: prNumber, + targetKind: "pull_request", + currentCreatedAtMs: Date.now(), + })], + ]; + for (const [label, collapse] of cleanupTasks) { + try { + const collapsed = collapse(); + if (collapsed > 0) console.log(`Collapsed ${collapsed} previous ${label} comment(s).`); + } catch (err: unknown) { + console.warn(`Failed to collapse previous ${label} comments for ${repo}#${prNumber}: ${errorText(err)}`); + } } } +let orchestrateFinalCommentHandled = false; + function createOrchestrateStopComment(decision: HandoffDecision): void { + if (orchestrateFinalCommentHandled) return; const target = parsePositiveTargetNumber(targetNumber); - if (!repo || !target || !["issue", "pull_request"].includes(normalizeToken(sourceTargetKind))) { + const targetKind = normalizeToken(sourceTargetKind); + if (!repo || !target || !["issue", "pull_request"].includes(targetKind)) { return; } const body = formatOrchestrateStopComment(decision); - if (hasMatchingOrchestrateStopComment(repo, target, body)) { + const bodyWithFooter = appendRunDisplayFooter(body, modelDisplay); + const existingFinal = findTrustedOrchestrateFinalComment(repo, target); + + if (targetKind === "pull_request") { + const merged = tryMergeProgressFinalComment({ + repo, + commentId: progressCommentId, + mode: progressFinalCommentMode, + finalBody: body, + footer: modelDisplay, + }); + if (merged && existingFinal?.id && String(existingFinal.id) !== progressCommentId.trim()) { + try { + updateIssueComment(repo, existingFinal.id, [ + "Sepo orchestration continued in a newer run.", + "", + "The latest finalized orchestration note is in the current run's progress comment.", + ].join("\n")); + } catch (err: unknown) { + console.warn(`Failed to supersede previous orchestrator final comment: ${errorText(err)}`); + } + } + if (!merged) { + if (existingFinal?.id) { + updateIssueComment(repo, existingFinal.id, bodyWithFooter); + console.log("Updated orchestrator final comment."); + } else { + const action = upsertPrCommentByMarker(target, repo, ORCHESTRATE_FINAL_MARKER, bodyWithFooter); + console.log(`${action === "updated" ? "Updated" : "Created"} orchestrator final comment.`); + } + } + orchestrateFinalCommentHandled = true; + collapseSuccessfulPrArtifacts(target, decision); return; } - createIssueComment(repo, target, body); + + if (existingFinal?.id) { + updateIssueComment(repo, existingFinal.id, bodyWithFooter); + console.log("Updated orchestrator final comment."); + orchestrateFinalCommentHandled = true; + return; + } + const merged = tryMergeProgressFinalComment({ + repo, + commentId: progressCommentId, + mode: progressFinalCommentMode, + finalBody: body, + footer: modelDisplay, + }); + if (!merged) createIssueComment(repo, target, bodyWithFooter); + orchestrateFinalCommentHandled = true; } function commentOnInitialOrchestrateStop(decision: HandoffDecision): void { @@ -1245,6 +1417,17 @@ function commentOnTerminalMetaOrchestratorStop(decision: HandoffDecision): void createOrchestrateStopComment(decision); } +function commentOnTerminalPullRequestStop(decision: HandoffDecision, reportedToParent: boolean): void { + if ( + decision.decision !== "stop" || + reportedToParent || + normalizeToken(sourceTargetKind) !== "pull_request" + ) { + return; + } + createOrchestrateStopComment(decision); +} + function decideManualOrchestration(): HandoffDecision { const nextRound = currentRound + 1; if (currentRound >= maxRounds) { @@ -1293,17 +1476,7 @@ function decideManualOrchestration(): HandoffDecision { function decidePlannerOrchestration(): HandoffDecision { const nextRound = currentRound + 1; - const normalizedKind = normalizeToken(sourceTargetKind); - if (normalizedKind === "pull_request") { - const status = readPrStatus(repo, targetNumber); - if (!status) { - return { decision: "stop", reason: "could not read pull request status", nextRound }; - } - if (status.state !== "OPEN") { - return { decision: "stop", reason: `pull request is ${status.state.toLowerCase()}`, nextRound }; - } - } - return decideHandoff({ + const plannedDecision = decideHandoff({ automationMode, sourceAction, sourceConclusion, @@ -1318,6 +1491,31 @@ function decidePlannerOrchestration(): HandoffDecision { sourceHandoffContext, plannerDecision: readPlannerDecision(), }); + const normalizedKind = normalizeToken(sourceTargetKind); + if (normalizedKind === "pull_request") { + const status = readPrStatus(repo, targetNumber); + if (!status) { + return { + decision: "stop", + reason: "could not read pull request status", + nextRound, + plannerDecisionKind: plannedDecision.plannerDecisionKind, + userMessage: plannedDecision.userMessage, + clarificationRequest: plannedDecision.clarificationRequest, + }; + } + if (status.state !== "OPEN") { + return { + decision: "stop", + reason: `pull request is ${status.state.toLowerCase()}`, + nextRound, + plannerDecisionKind: plannedDecision.plannerDecisionKind, + userMessage: plannedDecision.userMessage, + clarificationRequest: plannedDecision.clarificationRequest, + }; + } + } + return plannedDecision; } function validateInitialOrchestrateCapabilities(): HandoffDecision | null { @@ -1335,26 +1533,28 @@ function validateInitialOrchestrateCapabilities(): HandoffDecision | null { } const authorizationStop = validateInitialOrchestrateCapabilities(); -const routeDecision = authorizationStop || (normalizeToken(sourceAction) === "orchestrate" - ? automationMode === "agent" && - ["issue", "pull_request"].includes(normalizeToken(sourceTargetKind)) +const plannerTargetSupported = ["issue", "pull_request"].includes(normalizeToken(sourceTargetKind)); +const routeDecision = authorizationStop || ( + automationMode === "agent" && plannerTargetSupported ? decidePlannerOrchestration() - : decideManualOrchestration() - : decideHandoff({ - automationMode, - sourceAction, - sourceConclusion, - sourceRecommendedNextStep, - targetKind: sourceTargetKind, - targetNumber, - nextTargetNumber: process.env.NEXT_TARGET_NUMBER || "", - currentRound, - maxRounds, - allowSelfApprove, - allowSelfMerge, - sourceHandoffContext, - plannerDecision: automationMode === "agent" ? readPlannerDecision() : null, - })); + : normalizeToken(sourceAction) === "orchestrate" + ? decideManualOrchestration() + : decideHandoff({ + automationMode, + sourceAction, + sourceConclusion, + sourceRecommendedNextStep, + targetKind: sourceTargetKind, + targetNumber, + nextTargetNumber: process.env.NEXT_TARGET_NUMBER || "", + currentRound, + maxRounds, + allowSelfApprove, + allowSelfMerge, + sourceHandoffContext, + plannerDecision: automationMode === "agent" ? readPlannerDecision() : null, + }) +); const decision = routeDecision; if (decision.decision === "dispatch" && decision.nextAction === "fix-pr" && !decision.handoffContext) { @@ -1370,6 +1570,7 @@ setOutput("handoff_context", decision.handoffContext || ""); setOutput("deduped", "false"); setOutput("dedupe_key", ""); setOutput("marker_comment_id", ""); +finalizeOrchestrationProgress(); if (decision.decision !== "dispatch" && decision.decision !== "delegate_issue") { console.log(`Handoff ${decision.decision}: ${decision.reason}`); @@ -1377,7 +1578,8 @@ if (decision.decision !== "dispatch" && decision.decision !== "delegate_issue") commentOnPlannerClarificationStop(decision); commentOnInitialOrchestrateStop(decision); commentOnUnsatisfactoryActionStop(decision); - reportTerminalToParent(decision); + const reportedToParent = reportTerminalToParent(decision); + commentOnTerminalPullRequestStop(decision, reportedToParent); commentOnTerminalMetaOrchestratorStop(decision); } catch (err: unknown) { console.warn(`Failed to report terminal sub-orchestration state: ${errorText(err)}`); diff --git a/.agent/src/cli/publish-orchestration-progress.ts b/.agent/src/cli/publish-orchestration-progress.ts new file mode 100644 index 00000000..ba7a4e64 --- /dev/null +++ b/.agent/src/cli/publish-orchestration-progress.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// CLI: publish the initial report-only progress note for an orchestrator run. +// This runs in a deterministic write-scoped job; the agent planner remains read-only. +// +// Env: AGENT_PROGRESS_POLICY, GITHUB_REPOSITORY, GITHUB_RUN_ID, ROUTE, +// TARGET_KIND, TARGET_NUMBER +// Output: progress_comment_id + +import { + createIssueComment, + findLatestTrustedIssueCommentByMarker, + updateIssueComment, +} from "../github.js"; +import { setOutput } from "../output.js"; +import { resolveProgressPolicy } from "./progress/resolve-policy.js"; +import { + buildProgressViewModel, + progressMarker, + renderRunning, +} from "../progress-render.js"; + +function positiveTargetNumber(value: string): number { + const normalized = String(value || "").trim(); + if (!/^\d+$/.test(normalized)) return 0; + const parsed = Number.parseInt(normalized, 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0; +} + +export function runPublishOrchestrationProgressCli( + env: NodeJS.ProcessEnv = process.env, +): number { + const route = String(env.ROUTE || "orchestrator").trim() || "orchestrator"; + const resolution = resolveProgressPolicy({ + ...env, + ORCHESTRATION_ENABLED: "true", + ROUTE: route, + }); + + if (!resolution.enabled || !resolution.targetSupported) { + console.log( + `orchestration progress skipped: mode=${resolution.mode}; target_supported=${resolution.targetSupported}`, + ); + return 0; + } + + const repo = String(env.GITHUB_REPOSITORY || "").trim(); + const targetNumber = positiveTargetNumber(env.TARGET_NUMBER || ""); + if (!repo || !targetNumber) { + throw new Error("GITHUB_REPOSITORY and a positive TARGET_NUMBER are required"); + } + + const runId = String(env.GITHUB_RUN_ID || "unknown"); + const body = renderRunning(buildProgressViewModel("", { + runId, + route, + })); + const existing = findLatestTrustedIssueCommentByMarker( + targetNumber, + repo, + progressMarker(runId), + ); + const commentId = existing?.id || createIssueComment(repo, targetNumber, body); + if (!commentId) { + throw new Error("GitHub returned an empty orchestration progress comment id"); + } + if (existing) { + updateIssueComment(repo, commentId, body); + } + + setOutput("progress_comment_id", commentId); + console.log(`${existing ? "Updated" : "Published"} orchestration progress comment ${commentId}.`); + return 0; +} + +if (require.main === module) { + try { + process.exitCode = runPublishOrchestrationProgressCli(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error(`Failed to publish orchestration progress: ${message}`); + process.exitCode = 1; + } +} diff --git a/.agent/src/cli/session-persist.ts b/.agent/src/cli/session-persist.ts new file mode 100644 index 00000000..eb22d63e --- /dev/null +++ b/.agent/src/cli/session-persist.ts @@ -0,0 +1,113 @@ +import { configureBotIdentity } from "../git.js"; +import { setOutput } from "../output.js"; +import { hasValidThreadTargetNumber } from "../session-bundle.js"; +import { persistSessionRunState } from "../session-state-persistence.js"; +import { parseSessionPolicy, tracksThreadState } from "../session-policy.js"; +import type { + ThreadBundleRestoreStatus, + ThreadResumeStatus, +} from "../thread-state.js"; + +const RESUME_STATUSES = new Set([ + "not_attempted", + "resumed", + "fallback_fresh", + "failed", +]); +const BUNDLE_RESTORE_STATUSES = new Set([ + "not_attempted", + "not_available", + "restored", + "restored_from_fork", + "failed", +]); + +function parseResumeStatus(value: string): ThreadResumeStatus | null { + const normalized = value || "not_attempted"; + return RESUME_STATUSES.has(normalized as ThreadResumeStatus) + ? (normalized as ThreadResumeStatus) + : null; +} + +function parseBundleRestoreStatus(value: string): ThreadBundleRestoreStatus | null { + const normalized = !value || value === "not_applicable" ? "not_attempted" : value; + return BUNDLE_RESTORE_STATUSES.has(normalized as ThreadBundleRestoreStatus) + ? (normalized as ThreadBundleRestoreStatus) + : null; +} + +function currentRunUrl(): string { + const server = process.env.GITHUB_SERVER_URL || "https://github.com"; + const repo = process.env.GITHUB_REPOSITORY || ""; + const runId = process.env.GITHUB_RUN_ID || ""; + return repo && runId ? `${server}/${repo}/actions/runs/${runId}` : ""; +} + +const repoRoot = process.env.GITHUB_WORKSPACE || process.cwd(); +const repoSlug = process.env.GITHUB_REPOSITORY || ""; +const route = process.env.ROUTE || ""; +const targetKind = process.env.TARGET_KIND || ""; +const targetNumber = Number(process.env.TARGET_NUMBER || "0"); +const lane = process.env.LANE || "default"; +const policy = parseSessionPolicy(process.env.SESSION_POLICY); +const exitCodeRaw = process.env.AGENT_EXIT_CODE || ""; +const exitCode = Number(exitCodeRaw); +const expectedThreadKey = process.env.THREAD_KEY || ""; +const resumeStatus = parseResumeStatus(process.env.RESUME_STATUS || ""); +const bundleRestoreStatus = parseBundleRestoreStatus( + process.env.SESSION_BUNDLE_RESTORE_STATUS || "", +); + +setOutput("persisted", "false"); + +if (!policy) { + console.error("Missing or invalid SESSION_POLICY"); + process.exitCode = 2; +} else if (!tracksThreadState(policy)) { + console.log("Session policy does not track thread state; skipping persistence."); +} else if ( + !repoSlug || + !route || + !targetKind || + !expectedThreadKey || + !hasValidThreadTargetNumber(targetKind, targetNumber) || + !/^\d+$/.test(exitCodeRaw) || + !Number.isSafeInteger(exitCode) || + !resumeStatus || + !bundleRestoreStatus +) { + console.error("Missing or invalid session-state inputs"); + process.exitCode = 2; +} else { + const token = process.env.INPUT_GITHUB_TOKEN || process.env.GH_TOKEN || ""; + configureBotIdentity(repoRoot); + const state = persistSessionRunState({ + repoRoot, + repoSlug, + route, + targetKind, + targetNumber, + lane, + expectedThreadKey, + exitCode, + acpxRecordId: process.env.ACPX_RECORD_ID || "", + acpxSessionId: process.env.ACPX_SESSION_ID || "", + resumeStatus, + lastResumeError: process.env.LAST_RESUME_ERROR || "", + resumedFromSessionId: process.env.RESUMED_FROM_SESSION_ID || "", + bundleRestoreStatus, + lastBundleRestoreError: process.env.SESSION_BUNDLE_RESTORE_ERROR || "", + forkedFromThreadKey: process.env.SESSION_FORK_FROM_THREAD_KEY || "", + forkedFromAcpxSessionId: process.env.SESSION_FORK_ACPX_SESSION_ID || "", + lastRunUrl: currentRunUrl(), + pushOptions: { + repo: repoSlug, + ...(token ? { token } : {}), + }, + }); + setOutput("persisted", "true"); + setOutput("thread_key", state.thread_key); + console.log( + `Persisted thread state ${state.thread_key} at attempt ${state.attempt_count}.`, + ); +} diff --git a/.agent/src/cli/session-restore.ts b/.agent/src/cli/session-restore.ts index bb37874c..24016f48 100644 --- a/.agent/src/cli/session-restore.ts +++ b/.agent/src/cli/session-restore.ts @@ -110,18 +110,21 @@ function tryRestoreDestination(args: { runnerTemp: string; homeDir: string; threadStateOpts: PushOptions; + persistThreadState: boolean; }): DestinationRestoreStatus { const artifactName = args.state?.session_bundle_artifact_name || ""; const artifactRunId = args.state?.session_bundle_run_id || ""; const artifactBackend = args.state?.session_bundle_backend || ""; if (!artifactName || !artifactRunId || !isRestorableSessionBundleBackend(artifactBackend)) { - markThreadBundleRestore( - args.threadKey, - args.repoRoot, - { bundle_restore_status: "not_available", last_bundle_restore_error: "" }, - args.threadStateOpts, - ); + if (args.persistThreadState) { + markThreadBundleRestore( + args.threadKey, + args.repoRoot, + { bundle_restore_status: "not_available", last_bundle_restore_error: "" }, + args.threadStateOpts, + ); + } setOutput("restore_status", "not_available"); return "not_available"; } @@ -135,24 +138,28 @@ function tryRestoreDestination(args: { artifactName, artifactRunId, }); - markThreadBundleRestore( - args.threadKey, - args.repoRoot, - { bundle_restore_status: "restored", last_bundle_restore_error: "" }, - args.threadStateOpts, - ); + if (args.persistThreadState) { + markThreadBundleRestore( + args.threadKey, + args.repoRoot, + { bundle_restore_status: "restored", last_bundle_restore_error: "" }, + args.threadStateOpts, + ); + } setOutput("restore_status", "restored"); setOutput("artifact_name", artifactName); setOutput("artifact_run_id", artifactRunId); return "restored"; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - markThreadBundleRestore( - args.threadKey, - args.repoRoot, - { bundle_restore_status: "failed", last_bundle_restore_error: msg }, - args.threadStateOpts, - ); + if (args.persistThreadState) { + markThreadBundleRestore( + args.threadKey, + args.repoRoot, + { bundle_restore_status: "failed", last_bundle_restore_error: msg }, + args.threadStateOpts, + ); + } setOutput("restore_status", "failed"); setOutput("restore_error", msg); setOutput("artifact_name", artifactName); @@ -256,6 +263,8 @@ const runnerTemp = process.env.RUNNER_TEMP || tmpdir(); const policy = parseSessionPolicy(process.env.SESSION_POLICY); const bundleMode = parseSessionBundleMode(process.env.SESSION_BUNDLE_MODE); const forkFromThreadKey = String(process.env.SESSION_FORK_FROM_THREAD_KEY || "").trim(); +const persistThreadState = + String(process.env.DEFER_SESSION_STATE_PERSISTENCE || "").trim().toLowerCase() !== "true"; setDefaultOutputs(); @@ -294,6 +303,7 @@ if (!policy) { runnerTemp, homeDir, threadStateOpts, + persistThreadState, }); if (destinationRestoreStatus !== "restored" && !state?.acpxSessionId) { diff --git a/.agent/src/github.ts b/.agent/src/github.ts index 424ff83c..de595c62 100644 --- a/.agent/src/github.ts +++ b/.agent/src/github.ts @@ -407,14 +407,13 @@ export function fetchIssueCommentRecords(issueNumber: number, repo: string): Iss return comments; } -export function upsertPrCommentByMarker( - prNumber: number, +export function findLatestTrustedIssueCommentByMarker( + issueNumber: number, repo: string, marker: string, - body: string, -): "created" | "updated" { +): IssueCommentRecord | undefined { const trustedActor = normalizeActorLogin(fetchAuthenticatedActorLogin()); - const existing = fetchIssueCommentRecords(prNumber, repo) + const existing = fetchIssueCommentRecords(issueNumber, repo) .filter((comment) => ( comment.id && comment.body.includes(marker) && @@ -422,7 +421,16 @@ export function upsertPrCommentByMarker( normalizeActorLogin(comment.authorLogin) === trustedActor )) .sort((left, right) => createdAtMs(left.createdAt) - createdAtMs(right.createdAt)); - const latest = existing[existing.length - 1]; + return existing[existing.length - 1]; +} + +export function upsertPrCommentByMarker( + prNumber: number, + repo: string, + marker: string, + body: string, +): "created" | "updated" { + const latest = findLatestTrustedIssueCommentByMarker(prNumber, repo, marker); if (latest) { updateIssueComment(repo, latest.id, body); return "updated"; diff --git a/.agent/src/handoff.ts b/.agent/src/handoff.ts index 75c0873c..963b8028 100644 --- a/.agent/src/handoff.ts +++ b/.agent/src/handoff.ts @@ -78,6 +78,8 @@ const PLANNER_DECISION_KINDS: Partial> = { blocked: "blocked", }; const HANDOFF_MARKER_PREFIX = "sepo-agent-handoff"; +export const ORCHESTRATE_FINAL_MARKER = ""; +export const LEGACY_ORCHESTRATE_STOP_MARKER = ""; const DEFAULT_FIX_PR_HANDOFF_CONTEXT = [ "Address only the latest unresolved review synthesis action items.", "Ignore optional INFO notes, metadata-only polish, already-fixed findings, and human-judgment nits unless required by the selected fix.", @@ -99,6 +101,11 @@ function escapeRegex(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +export function hasAnyOrchestrateFinalMarker(body: string): boolean { + const text = String(body || ""); + return text.includes(ORCHESTRATE_FINAL_MARKER) || text.includes(LEGACY_ORCHESTRATE_STOP_MARKER); +} + export function normalizeAutomationMode(value: string): AutomationMode { const normalized = normalizeToken(String(value || "")); if (!normalized || normalized === "false") { diff --git a/.agent/src/progress-final-comment.ts b/.agent/src/progress-final-comment.ts index ee81826d..ec8f6c7a 100644 --- a/.agent/src/progress-final-comment.ts +++ b/.agent/src/progress-final-comment.ts @@ -2,6 +2,7 @@ import { fetchIssueCommentBody, updateIssueComment, } from "./github.js"; +import { buildProgressViewModel, renderFinal } from "./progress-render.js"; import { appendRunDisplayFooter } from "./response.js"; export interface ProgressFinalCommentOptions { @@ -13,6 +14,16 @@ export interface ProgressFinalCommentOptions { log?: (message: string) => void; } +export interface ProgressActivityFinalizationOptions { + repo: string; + commentId: string; + mode: string; + streamText: string; + runId: string; + route: string; + log?: (message: string) => void; +} + const PROGRESS_MARKER_RE = //; const ACTIVITY_DETAILS_RE = /
\s*Activity<\/summary>\s*([\s\S]*?)<\/details>/m; @@ -63,6 +74,32 @@ export function tryMergeProgressFinalComment(options: ProgressFinalCommentOption } } +export function tryFinalizeProgressActivity( + options: ProgressActivityFinalizationOptions, +): boolean { + const mode = String(options.mode || "").trim().toLowerCase(); + const repo = options.repo.trim(); + const commentId = options.commentId.trim(); + if (mode !== "merge" || !repo || !commentId) { + return false; + } + + try { + const model = buildProgressViewModel(options.streamText, { + runId: options.runId, + route: options.route, + status: "finalized", + }); + updateIssueComment(repo, commentId, renderFinal(model, "finished")); + return true; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const log = options.log ?? console.warn; + log(`Failed to finalize progress comment ${commentId}: ${message}`); + return false; + } +} + function extractProgressActivity(progressBody: string): string { const body = String(progressBody || "").replace(PROGRESS_MARKER_RE, "").trim(); if (!body) return ""; diff --git a/.agent/src/progress-render.ts b/.agent/src/progress-render.ts index eac353ce..4a55ef04 100644 --- a/.agent/src/progress-render.ts +++ b/.agent/src/progress-render.ts @@ -14,7 +14,7 @@ export interface ProgressViewModel { status: ProgressStatus; runId: string; route?: string; - elapsedMs: number; + elapsedMs?: number; stepCount: number; recentActivity: ProgressActivity[]; lastMessage?: string; @@ -134,7 +134,9 @@ export function buildProgressViewModel( status: options.status ?? "running", runId: normalizeRunId(options.runId), route: cleanSingleLine(options.route ?? ""), - elapsedMs: Math.max(0, Math.floor(options.elapsedMs ?? 0)), + elapsedMs: options.elapsedMs === undefined + ? undefined + : Math.max(0, Math.floor(options.elapsedMs)), stepCount: normalizeStepCount(options.totalStepCount, stepCount), recentActivity: allActivity.slice(-recentActivityLimit), lastMessage: lastMessage || undefined, @@ -416,7 +418,7 @@ function toolDetail(toolName: string, label: string): string | undefined { function renderMeta(model: Pick): string { const parts = [ model.route?.trim() || undefined, - formatElapsed(model.elapsedMs), + model.elapsedMs === undefined ? undefined : formatElapsed(model.elapsedMs), `${model.stepCount} ${model.stepCount === 1 ? "step" : "steps"}`, ].filter(Boolean); return parts.length ? ` — ${parts.join(" · ")}` : ""; diff --git a/.agent/src/review-summary-minimize.ts b/.agent/src/review-summary-minimize.ts index f974614b..59eeef23 100644 --- a/.agent/src/review-summary-minimize.ts +++ b/.agent/src/review-summary-minimize.ts @@ -2,7 +2,11 @@ import { createGhGraphqlClient, type GraphQLClient, } from "./github-graphql.js"; -import { hasAnyHandoffMarker, parseAnyHandoffMarker } from "./handoff.js"; +import { + hasAnyHandoffMarker, + hasAnyOrchestrateFinalMarker, + parseAnyHandoffMarker, +} from "./handoff.js"; import { isFixPrStatusBody } from "./fix-pr-status.js"; import { isReviewSynthesisBody } from "./review-synthesis.js"; @@ -205,7 +209,9 @@ function isGeneratedReviewComment( ): boolean { if (!node.id || node.isMinimized) return false; if (!isSameActorLogin(node.author?.login || "", viewerLogin)) return false; - return bodyMatcher(node.body || ""); + const body = node.body || ""; + if (hasAnyOrchestrateFinalMarker(body)) return false; + return bodyMatcher(body); } function fetchViewerLogin(client: GraphQLClient): string { diff --git a/.agent/src/run.ts b/.agent/src/run.ts index 10072fc5..44652641 100644 --- a/.agent/src/run.ts +++ b/.agent/src/run.ts @@ -449,6 +449,7 @@ function main(): void { setOutput("model_display", ""); setOutput("resume_status", "not_attempted"); setOutput("last_resume_error", ""); + setOutput("resumed_from_session_id", ""); setOutput( "session_bundle_restore_status", process.env.SESSION_BUNDLE_RESTORE_STATUS || "not_attempted", @@ -514,6 +515,9 @@ function runDirectPath(opts: { return; } const trackThreadState = tracksThreadState(sessionPolicy) && Boolean(envelope.thread_key); + const deferSessionStatePersistence = parseBooleanFlag( + process.env.DEFER_SESSION_STATE_PERSISTENCE, + ); const threadStateOpts = buildThreadStateOptions(envelope); let threadState: ThreadState | null = null; @@ -556,28 +560,35 @@ function runDirectPath(opts: { }); } - threadState = markThreadRunning( - envelope.thread_key, - repoRoot, - { - last_run_url: currentRunUrl(), - ...buildRunningThreadStateFields(), - ...(forkResumeSessionId - ? { - forked_from_thread_key: forkFromThreadKey, - forked_from_acpx_session_id: forkAcpxSessionId, - bundle_restore_status: "restored_from_fork" as const, - last_bundle_restore_error: "", - } - : {}), - }, - threadStateOpts, - ); - log("info", "Thread state marked running", { - thread_key: envelope.thread_key, - attempt: threadState.attempt_count, - session_policy: sessionPolicy, - }); + if (deferSessionStatePersistence) { + log("info", "Thread state persistence deferred to a trusted workflow step", { + thread_key: envelope.thread_key, + session_policy: sessionPolicy, + }); + } else { + threadState = markThreadRunning( + envelope.thread_key, + repoRoot, + { + last_run_url: currentRunUrl(), + ...buildRunningThreadStateFields(), + ...(forkResumeSessionId + ? { + forked_from_thread_key: forkFromThreadKey, + forked_from_acpx_session_id: forkAcpxSessionId, + bundle_restore_status: "restored_from_fork" as const, + last_bundle_restore_error: "", + } + : {}), + }, + threadStateOpts, + ); + log("info", "Thread state marked running", { + thread_key: envelope.thread_key, + attempt: threadState.attempt_count, + session_policy: sessionPolicy, + }); + } if (shouldFailBecauseRequiredResumeIdentityMissing(sessionPolicy, existingThreadState, resumeSessionId)) { const missingResumeError = "resume-required route has prior thread state but no acpxSessionId to resume"; @@ -587,7 +598,9 @@ function runDirectPath(opts: { kind: "failed", error: missingResumeError, }); - markThreadFailed(envelope.thread_key, threadState, repoRoot, failedUpdates, threadStateOpts); + if (threadState) { + markThreadFailed(envelope.thread_key, threadState, repoRoot, failedUpdates, threadStateOpts); + } log("error", "Session continuity requirement not satisfied: prior thread state exists without resumable session identity", { thread_key: envelope.thread_key, session_policy: sessionPolicy, @@ -634,6 +647,7 @@ function runDirectPath(opts: { const resumeFields = buildThreadStateFieldsFromEnsureOutcome(result.sessionEnsureOutcome); setOutput("resume_status", resumeFields.resume_status); setOutput("last_resume_error", resumeFields.last_resume_error); + setOutput("resumed_from_session_id", resumeFields.resumed_from_session_id); log("info", "acpx completed", { exit_code: result.exitCode, @@ -692,7 +706,7 @@ function runDirectPath(opts: { } } - if (trackThreadState && threadState) { + if (trackThreadState && threadState && !deferSessionStatePersistence) { try { if (result.exitCode !== 0) { const failedUpdates = buildFailedThreadStateUpdates(result.sessionEnsureOutcome); diff --git a/.agent/src/session-state-persistence.ts b/.agent/src/session-state-persistence.ts new file mode 100644 index 00000000..1669cdf8 --- /dev/null +++ b/.agent/src/session-state-persistence.ts @@ -0,0 +1,99 @@ +import { buildThreadKey } from "./envelope.js"; +import { + type PushOptions, + type ThreadBundleRestoreStatus, + type ThreadResumeStatus, + type ThreadState, + markThreadCompleted, + markThreadFailed, + markThreadRunning, +} from "./thread-state.js"; + +export interface SessionRunStateInput { + repoRoot: string; + repoSlug: string; + route: string; + targetKind: string; + targetNumber: number; + lane?: string; + expectedThreadKey?: string; + exitCode: number; + acpxRecordId?: string; + acpxSessionId?: string; + resumeStatus?: ThreadResumeStatus; + lastResumeError?: string; + resumedFromSessionId?: string; + bundleRestoreStatus?: ThreadBundleRestoreStatus; + lastBundleRestoreError?: string; + forkedFromThreadKey?: string; + forkedFromAcpxSessionId?: string; + lastRunUrl?: string; + pushOptions?: PushOptions; +} + +export function persistSessionRunState(input: SessionRunStateInput): ThreadState { + const threadKey = buildThreadKey({ + repo_slug: input.repoSlug, + route: input.route, + target_kind: input.targetKind, + target_number: input.targetNumber, + lane: input.lane, + }); + if (input.expectedThreadKey && input.expectedThreadKey !== threadKey) { + throw new Error( + `Thread key mismatch: expected ${threadKey}, received ${input.expectedThreadKey}`, + ); + } + + const running = markThreadRunning( + threadKey, + input.repoRoot, + { + last_run_url: input.lastRunUrl || "", + resume_status: "not_attempted", + last_resume_error: "", + resumed_from_session_id: "", + bundle_restore_status: input.bundleRestoreStatus || "not_attempted", + last_bundle_restore_error: input.lastBundleRestoreError || "", + ...(input.forkedFromThreadKey + ? { forked_from_thread_key: input.forkedFromThreadKey } + : {}), + ...(input.forkedFromAcpxSessionId + ? { forked_from_acpx_session_id: input.forkedFromAcpxSessionId } + : {}), + }, + input.pushOptions, + ); + + const resumeUpdates = { + resume_status: input.resumeStatus || "not_attempted", + last_resume_error: input.lastResumeError || "", + resumed_from_session_id: input.resumedFromSessionId || "", + }; + if (input.exitCode !== 0) { + return markThreadFailed( + threadKey, + running, + input.repoRoot, + resumeUpdates, + input.pushOptions, + ); + } + + const identityUpdates = input.acpxRecordId && input.acpxSessionId + ? { + acpxRecordId: input.acpxRecordId, + acpxSessionId: input.acpxSessionId, + } + : {}; + return markThreadCompleted( + threadKey, + running, + input.repoRoot, + { + ...resumeUpdates, + ...identityUpdates, + }, + input.pushOptions, + ); +} diff --git a/.github/actions/run-agent-task/action.yml b/.github/actions/run-agent-task/action.yml index c61ba734..0599f4af 100644 --- a/.github/actions/run-agent-task/action.yml +++ b/.github/actions/run-agent-task/action.yml @@ -109,6 +109,10 @@ inputs: session_policy: description: "Session continuity policy (none, track-only, resume-best-effort, resume-required)" required: true + defer_session_state_persistence: + description: "Defer thread-state and bundle registration writes to a separately authorized workflow step" + required: false + default: "false" session_bundle_mode: description: "Session bundle persistence mode (auto, always, never)" required: false @@ -202,6 +206,9 @@ outputs: last_resume_error: description: "Session continuity error when resume failed" value: ${{ steps.run.outputs.last_resume_error }} + resumed_from_session_id: + description: "Prior acpx session ID used for a successful or attempted resume" + value: ${{ steps.run.outputs.resumed_from_session_id }} session_bundle_restore_status: description: "Result of restoring a prior session bundle artifact" value: ${{ steps.run.outputs.session_bundle_restore_status }} @@ -223,6 +230,9 @@ outputs: session_bundle_artifact_name: description: "Uploaded session bundle artifact name" value: ${{ steps.bundle.outputs.artifact_name }} + agent_exit_code: + description: "Exit code produced by the model-backed runtime step" + value: ${{ steps.run.outputs.exit_code }} memory_mode: description: "Resolved memory mode (enabled, read-only, disabled)" value: ${{ steps.memory_mode.outputs.mode }} @@ -290,6 +300,7 @@ runs: LANE: ${{ inputs.lane }} SESSION_POLICY: ${{ inputs.session_policy }} SESSION_BUNDLE_MODE: ${{ inputs.session_bundle_mode }} + DEFER_SESSION_STATE_PERSISTENCE: ${{ inputs.defer_session_state_persistence }} SESSION_FORK_FROM_THREAD_KEY: ${{ inputs.session_fork_from_thread_key }} TARGET_KIND: ${{ inputs.target_kind }} TARGET_NUMBER: ${{ inputs.target_number }} @@ -475,6 +486,7 @@ runs: LANE: ${{ inputs.lane }} SESSION_POLICY: ${{ inputs.session_policy }} SESSION_BUNDLE_MODE: ${{ inputs.session_bundle_mode }} + DEFER_SESSION_STATE_PERSISTENCE: ${{ inputs.defer_session_state_persistence }} SESSION_BUNDLE_RESTORE_STATUS: ${{ steps.restore.outputs.restore_status }} SESSION_BUNDLE_RESTORE_ERROR: ${{ steps.restore.outputs.restore_error }} SESSION_FORK_FROM_THREAD_KEY: ${{ steps.restore.outputs.fork_from_thread_key }} @@ -656,7 +668,7 @@ runs: retention-days: ${{ inputs.session_bundle_retention_days }} - name: Register session bundle artifact - if: always() && steps.run.outputs.exit_code == '0' && steps.upload_session_bundle.outputs.artifact-id != '' + if: always() && inputs.defer_session_state_persistence != 'true' && steps.run.outputs.exit_code == '0' && steps.upload_session_bundle.outputs.artifact-id != '' id: register_session_bundle continue-on-error: true shell: bash diff --git a/.github/prompts/agent-orchestrator.md b/.github/prompts/agent-orchestrator.md index d3760a8c..570eb312 100644 --- a/.github/prompts/agent-orchestrator.md +++ b/.github/prompts/agent-orchestrator.md @@ -74,7 +74,7 @@ rubrics. Then return exactly one JSON object and nothing else: "next_action": "implement | review | fix-pr | agent-self-approve | agent-self-merge", "reason": "Short explanation for logs and the handoff marker.", "handoff_context": "Actionable instructions for the next action, especially fix-pr.", - "user_message": "Optional user-facing message to post when decision is answer or blocked.", + "user_message": "Optional user-facing summary or message to post when decision is stop, answer, or blocked.", "clarification_request": "Optional focused question to post when decision is blocked.", "child_stage": "Short child issue stage name when decision is delegate_issue.", "child_instructions": "Concrete child issue task instructions when decision is delegate_issue.", @@ -112,6 +112,10 @@ Rules: and are safe for an automated agent to apply. - Use `stop` when the task appears complete, the result is unsupported, or the next step should be left to a human. +- For `stop`, include a concise `user_message` that summarizes the cumulative + work and terminal outcome from the current target's orchestration history. + If no substantive work completed, explain the remaining human action or + blocker instead. - Stop instead of handing off when the remaining items are metadata-only (for example PR title/body/labels/comments), optional suggestions, INFO-level notes, style or naming preferences, already-fixed findings, or other diff --git a/.github/workflows/agent-orchestrator.yml b/.github/workflows/agent-orchestrator.yml index 9c88521f..cf80c32d 100644 --- a/.github/workflows/agent-orchestrator.yml +++ b/.github/workflows/agent-orchestrator.yml @@ -76,34 +76,41 @@ on: default: "12" permissions: - actions: write contents: read - issues: write - pull-requests: read - id-token: write # required for GitHub Actions OIDC broker exchange concurrency: group: agent-orchestrator-${{ github.repository }}-${{ inputs.target_number }}-${{ inputs.source_action }}-${{ inputs.automation_current_round }} cancel-in-progress: false jobs: - orchestrate: + plan: if: vars.AGENT_ENABLED != 'false' runs-on: ${{ fromJson(vars.AGENT_RUNS_ON || '["ubuntu-latest"]') }} + permissions: + actions: read + contents: read + issues: read + pull-requests: read + outputs: + acpx_record_id: ${{ steps.planner.outputs.acpx_record_id }} + acpx_session_id: ${{ steps.planner.outputs.acpx_session_id }} + agent_exit_code: ${{ steps.planner.outputs.agent_exit_code }} + last_resume_error: ${{ steps.planner.outputs.last_resume_error }} + model_display: ${{ steps.planner.outputs.model_display }} + planner_response_artifact_id: ${{ steps.upload_planner_response.outputs.artifact-id }} + resumed_from_session_id: ${{ steps.planner.outputs.resumed_from_session_id }} + resume_status: ${{ steps.planner.outputs.resume_status }} + session_bundle_artifact_id: ${{ steps.planner.outputs.session_bundle_artifact_id }} + session_bundle_artifact_name: ${{ steps.planner.outputs.session_bundle_artifact_name }} + session_bundle_restore_error: ${{ steps.planner.outputs.session_bundle_restore_error }} + session_bundle_restore_status: ${{ steps.planner.outputs.session_bundle_restore_status }} + thread_key: ${{ steps.planner.outputs.thread_key }} steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.repository.default_branch }} token: ${{ github.token }} - - - name: Resolve GitHub auth - id: auth - uses: ./.github/actions/resolve-github-auth - with: - app_id: ${{ secrets.AGENT_APP_ID }} - app_private_key: ${{ secrets.AGENT_APP_PRIVATE_KEY }} - pat: ${{ secrets.AGENT_PAT }} - fallback_token: ${{ github.token }} + persist-credentials: false - name: Setup agent runtime uses: ./.github/actions/setup-agent-runtime @@ -170,7 +177,7 @@ jobs: ORCHESTRATOR_MAX_ROUNDS: ${{ inputs.automation_max_rounds }} with: agent: ${{ steps.provider.outputs.provider }} - github_token: ${{ steps.auth.outputs.token }} + github_token: ${{ github.token }} secondary_github_token: ${{ secrets.AGENT_SECONDARY_GITHUB_TOKEN }} openai_api_key: ${{ secrets.OPENAI_API_KEY }} claude_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -181,10 +188,11 @@ jobs: prompt: orchestrator reasoning_effort: ${{ steps.provider.outputs.reasoning_effort || 'high' }} lane: planner + defer_session_state_persistence: "true" memory_mode_override: read-only memory_ref: ${{ vars.AGENT_MEMORY_REF || 'agent/memory' }} memory_policy: ${{ vars.AGENT_MEMORY_POLICY || '' }} - progress_policy: ${{ vars.AGENT_PROGRESS_POLICY || '' }} + progress_policy: '{"orchestration_mode":"disabled"}' orchestration_enabled: "true" rubrics_ref: ${{ vars.AGENT_RUBRICS_REF || 'agent/rubrics' }} rubrics_policy: ${{ vars.AGENT_RUBRICS_POLICY || '' }} @@ -201,12 +209,188 @@ jobs: target_url: ${{ (inputs.target_kind || (inputs.source_action == 'implement' && 'issue' || 'pull_request')) == 'issue' && format('{0}/{1}/issues/{2}', github.server_url, github.repository, inputs.target_number) || format('{0}/{1}/pull/{2}', github.server_url, github.repository, inputs.target_number) }} workflow: agent-orchestrator.yml + - name: Upload planner response + id: upload_planner_response + if: always() && steps.planner.outputs.response_file != '' + uses: actions/upload-artifact@v4 + with: + name: agent-orchestrator-plan-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ steps.planner.outputs.response_file }} + ${{ steps.planner.outputs.session_log_file }} + retention-days: 1 + + progress: + if: ${{ vars.AGENT_ENABLED != 'false' && vars.AGENT_PROGRESS_POLICY != '' && !cancelled() }} + runs-on: ${{ fromJson(vars.AGENT_RUNS_ON || '["ubuntu-latest"]') }} + permissions: + contents: read + issues: write + pull-requests: write + id-token: write # required for GitHub Actions OIDC broker exchange + outputs: + progress_comment_id: ${{ steps.publish.outputs.progress_comment_id }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + token: ${{ github.token }} + persist-credentials: false + + - name: Setup agent runtime + uses: ./.github/actions/setup-agent-runtime + + - name: Resolve orchestration progress policy + id: progress_policy + env: + AGENT_PROGRESS_POLICY: ${{ vars.AGENT_PROGRESS_POLICY || '' }} + ORCHESTRATION_ENABLED: "true" + ROUTE: orchestrator + TARGET_KIND: ${{ inputs.target_kind || (inputs.source_action == 'implement' && 'issue' || 'pull_request') }} + run: node .agent/dist/cli/progress/resolve-policy.js + + - name: Resolve GitHub auth + id: auth + if: ${{ steps.progress_policy.outputs.enabled == 'true' && steps.progress_policy.outputs.target_supported == 'true' }} + uses: ./.github/actions/resolve-github-auth + with: + app_id: ${{ secrets.AGENT_APP_ID }} + app_private_key: ${{ secrets.AGENT_APP_PRIVATE_KEY }} + pat: ${{ secrets.AGENT_PAT }} + fallback_token: ${{ github.token }} + + - name: Publish orchestration progress + id: publish + if: ${{ steps.progress_policy.outputs.enabled == 'true' && steps.progress_policy.outputs.target_supported == 'true' }} + env: + AGENT_PROGRESS_POLICY: ${{ vars.AGENT_PROGRESS_POLICY || '' }} + GH_TOKEN: ${{ steps.auth.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + ROUTE: orchestrator + TARGET_KIND: ${{ inputs.target_kind || (inputs.source_action == 'implement' && 'issue' || 'pull_request') }} + TARGET_NUMBER: ${{ inputs.target_number }} + run: node .agent/dist/cli/publish-orchestration-progress.js + + decide-and-dispatch: + needs: [plan, progress] + if: ${{ vars.AGENT_ENABLED != 'false' && !cancelled() }} + runs-on: ${{ fromJson(vars.AGENT_RUNS_ON || '["ubuntu-latest"]') }} + permissions: + actions: write + contents: write + issues: write + pull-requests: write + id-token: write # required for GitHub Actions OIDC broker exchange + steps: + - uses: actions/checkout@v4 + id: resolver_checkout + with: + ref: ${{ github.event.repository.default_branch }} + token: ${{ github.token }} + persist-credentials: false + + - name: Resolve GitHub auth + id: auth + uses: ./.github/actions/resolve-github-auth + with: + app_id: ${{ secrets.AGENT_APP_ID }} + app_private_key: ${{ secrets.AGENT_APP_PRIVATE_KEY }} + pat: ${{ secrets.AGENT_PAT }} + fallback_token: ${{ github.token }} + + - name: Setup agent runtime + id: resolver_runtime + uses: ./.github/actions/setup-agent-runtime + + - name: Persist planner thread state + id: persist_session_state + if: ${{ !cancelled() && steps.resolver_checkout.outcome == 'success' && steps.auth.outcome == 'success' && steps.resolver_runtime.outcome == 'success' && needs.plan.outputs.agent_exit_code != '' }} + continue-on-error: true + env: + ACPX_RECORD_ID: ${{ needs.plan.outputs.acpx_record_id }} + ACPX_SESSION_ID: ${{ needs.plan.outputs.acpx_session_id }} + AGENT_EXIT_CODE: ${{ needs.plan.outputs.agent_exit_code }} + GH_TOKEN: ${{ steps.auth.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + INPUT_GITHUB_TOKEN: ${{ steps.auth.outputs.token }} + LANE: planner + LAST_RESUME_ERROR: ${{ needs.plan.outputs.last_resume_error }} + RESUMED_FROM_SESSION_ID: ${{ needs.plan.outputs.resumed_from_session_id }} + RESUME_STATUS: ${{ needs.plan.outputs.resume_status }} + ROUTE: orchestrator + SESSION_BUNDLE_RESTORE_ERROR: ${{ needs.plan.outputs.session_bundle_restore_error }} + SESSION_BUNDLE_RESTORE_STATUS: ${{ needs.plan.outputs.session_bundle_restore_status }} + SESSION_POLICY: resume-best-effort + TARGET_KIND: ${{ inputs.target_kind || (inputs.source_action == 'implement' && 'issue' || 'pull_request') }} + TARGET_NUMBER: ${{ inputs.target_number }} + THREAD_KEY: ${{ needs.plan.outputs.thread_key }} + run: node .agent/dist/cli/session-persist.js + + - name: Report planner thread-state persistence failure + if: ${{ !cancelled() && steps.persist_session_state.outcome == 'failure' }} + run: echo "::warning title=Planner session state not persisted::The planner completed, but its durable thread state could not be written." + + - name: Register planner session bundle + id: register_session_bundle + if: ${{ !cancelled() && steps.persist_session_state.outcome == 'success' && needs.plan.outputs.agent_exit_code == '0' && needs.plan.outputs.session_bundle_artifact_id != '' }} + continue-on-error: true + env: + INPUT_GITHUB_TOKEN: ${{ steps.auth.outputs.token }} + LANE: planner + ROUTE: orchestrator + SESSION_BUNDLE_ARTIFACT_ID: ${{ needs.plan.outputs.session_bundle_artifact_id }} + SESSION_BUNDLE_ARTIFACT_NAME: ${{ needs.plan.outputs.session_bundle_artifact_name }} + SESSION_BUNDLE_MODE: ${{ inputs.session_bundle_mode || vars.AGENT_SESSION_BUNDLE_MODE || 'auto' }} + SESSION_ID: ${{ needs.plan.outputs.acpx_session_id }} + SESSION_POLICY: resume-best-effort + SESSION_RECORD_ID: ${{ needs.plan.outputs.acpx_record_id }} + TARGET_KIND: ${{ inputs.target_kind || (inputs.source_action == 'implement' && 'issue' || 'pull_request') }} + TARGET_NUMBER: ${{ inputs.target_number }} + run: node .agent/dist/cli/session-register.js + + - name: Report planner session-bundle registration failure + if: ${{ !cancelled() && steps.register_session_bundle.outcome == 'failure' }} + run: echo "::warning title=Planner session bundle not registered::The planner bundle was uploaded, but its durable metadata could not be written." + + - name: Download planner response + id: download_planner_response + if: ${{ !cancelled() && needs.plan.outputs.planner_response_artifact_id != '' }} + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: agent-orchestrator-plan-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/agent-orchestrator-plan + + - name: Locate planner response + id: planner_response + if: ${{ !cancelled() && needs.plan.outputs.planner_response_artifact_id != '' && steps.download_planner_response.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + PLANNER_RESPONSE_DIR: ${{ runner.temp }}/agent-orchestrator-plan + run: | + set -euo pipefail + response_file="$(find "$PLANNER_RESPONSE_DIR" -type f -name '*.md' -print -quit)" + if [ -z "$response_file" ]; then + echo "No orchestrator planner response artifact found." >&2 + exit 1 + fi + session_log_file="$(find "$PLANNER_RESPONSE_DIR" -type f -name '*.jsonl' -print -quit)" + echo "response_file=${response_file}" >> "$GITHUB_OUTPUT" + echo "session_log_file=${session_log_file}" >> "$GITHUB_OUTPUT" + - name: Decide and dispatch next action + if: ${{ !cancelled() && steps.resolver_checkout.outcome == 'success' && steps.auth.outcome == 'success' && steps.resolver_runtime.outcome == 'success' }} env: AUTOMATION_CURRENT_ROUND: ${{ inputs.automation_current_round }} AUTOMATION_MAX_ROUNDS: ${{ inputs.automation_max_rounds }} AUTOMATION_MODE: ${{ inputs.automation_mode }} AGENT_COLLAPSE_OLD_REVIEWS: ${{ vars.AGENT_COLLAPSE_OLD_REVIEWS }} + AGENT_HANDLE: ${{ vars.AGENT_HANDLE || '@sepo-agent' }} + AGENT_PROGRESS_COMMENT_ID: ${{ needs.progress.outputs.progress_comment_id }} + AGENT_PROGRESS_FINAL_COMMENT_MODE: merge + AGENT_PROGRESS_STREAM_FILE: ${{ steps.planner_response.outputs.session_log_file }} BASE_BRANCH: ${{ inputs.base_branch }} BASE_PR: ${{ inputs.base_pr }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} @@ -228,5 +412,6 @@ jobs: REPOSITORY_PRIVATE: ${{ inputs.repository_private || (github.event.repository.private && 'true' || 'false') }} TARGET_KIND: ${{ inputs.target_kind || (inputs.source_action == 'implement' && 'issue' || 'pull_request') }} TARGET_NUMBER: ${{ inputs.target_number }} - PLANNER_RESPONSE_FILE: ${{ steps.planner.outputs.response_file }} + PLANNER_RESPONSE_FILE: ${{ steps.planner_response.outputs.response_file }} + MODEL_DISPLAY: ${{ needs.plan.outputs.model_display }} run: node .agent/dist/cli/orchestrate-handoff.js