diff --git a/control-server/package.json b/control-server/package.json index 1e49b5e..4ef8884 100644 --- a/control-server/package.json +++ b/control-server/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "start": "node src/server.mjs", - "test": "node --test test/*.test.mjs" + "test": "node --test test/*.test.mjs", + "test:e2e:github": "node test/github-unreviewed-pr.e2e.mjs" }, "dependencies": { "ws": "8.21.3" diff --git a/control-server/test/github-unreviewed-pr.e2e.mjs b/control-server/test/github-unreviewed-pr.e2e.mjs new file mode 100644 index 0000000..78d0d69 --- /dev/null +++ b/control-server/test/github-unreviewed-pr.e2e.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; + +const baseUrl = new URL(process.env.MULTIAGENT_E2E_URL || "http://127.0.0.1:18080"); +const repository = process.env.MULTIAGENT_E2E_REPOSITORY || "multiagent"; +const githubRepository = process.env.MULTIAGENT_E2E_GITHUB_REPOSITORY || "aptos-labs/aptos-core"; +const timeoutMs = Number(process.env.MULTIAGENT_E2E_TIMEOUT_MS || 30 * 60_000); +const sessionId = process.env.MULTIAGENT_E2E_SESSION_ID || `github-pr-e2e-${Date.now().toString(36)}`; + +const expected = await latestOpenPullRequestWithoutReviews(githubRepository); +console.log(`oracle: ${expected.html_url} (${expected.title})`); + +let cookie = process.env.MULTIAGENT_E2E_COOKIE || ""; +if (!cookie) { + const login = await request("/api/login", { + method: "POST", + body: { + username: required("MULTIAGENT_E2E_USERNAME"), + password: required("MULTIAGENT_E2E_PASSWORD"), + }, + }); + cookie = login.headers.get("set-cookie")?.split(";", 1)[0] || ""; + assert.ok(cookie, "control server did not issue an authentication cookie"); +} + +const repositories = await request("/api/repositories", { cookie }); +assert.ok(repositories.body.repositories.includes(repository), `repository is not configured: ${repository}`); + +const task = [ + `Read the latest open pull request in ${githubRepository} that has no submitted pull-request reviews.`, + "Use the GitHub Markdown runbook and access GitHub only through prod-mcp.", + "Return its PR number, title, author, URL, creation timestamp, and explicit evidence that its submitted review list is empty.", + "Do not clone or modify the repository.", +].join(" "); +await request("/api/sessions", { + method: "POST", + cookie, + body: { id: sessionId, repository, task }, + expectedStatus: 201, +}); +console.log(`session: ${sessionId}`); + +const deadline = Date.now() + timeoutMs; +let lastStatus = "pending"; +let lastReport = ""; +while (Date.now() < deadline) { + const sessions = await request("/api/sessions", { cookie }); + const session = sessions.body.sessions.find((candidate) => candidate.id === sessionId); + assert.ok(session, `session disappeared: ${sessionId}`); + if (session.status !== lastStatus) { + lastStatus = session.status; + console.log(`status: ${lastStatus}`); + } + try { + const report = await request(`/api/sessions/${sessionId}/report`, { cookie }); + lastReport = String(report.body.report || ""); + if (reportMatches(lastReport, expected)) { + console.log(lastReport); + console.log("GitHub unreviewed PR E2E passed"); + process.exit(0); + } + } catch {} + if (["failed", "archived"].includes(session.status)) { + throw new Error(`session ended with status ${session.status}\n${lastReport}`); + } + await sleep(10_000); +} +throw new Error(`timed out waiting for ${sessionId}; last status: ${lastStatus}\n${lastReport}`); + +function reportMatches(report, pullRequest) { + const number = String(pullRequest.number); + return report.includes(pullRequest.html_url) + && (report.includes(`#${number}`) || report.includes(`PR ${number}`) || report.includes(`pull request ${number}`)) + && /(?:zero|no|empty).{0,80}(?:submitted )?reviews?/is.test(report); +} + +async function latestOpenPullRequestWithoutReviews(repositoryName) { + const headers = { + accept: "application/vnd.github+json", + "user-agent": "multiagent-live-e2e", + "x-github-api-version": "2022-11-28", + }; + if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + const pulls = await githubJson(`https://api.github.com/repos/${repositoryName}/pulls?state=open&sort=created&direction=desc&per_page=30`, headers); + for (const pull of pulls) { + const reviews = await githubJson( + `https://api.github.com/repos/${repositoryName}/pulls/${pull.number}/reviews?per_page=1`, + headers, + ); + if (reviews.length === 0) return pull; + } + throw new Error(`no unreviewed open pull request found in the latest ${pulls.length} PRs`); +} + +async function githubJson(url, headers) { + const response = await fetch(url, { headers }); + const body = await response.json().catch(() => null); + if (!response.ok) throw new Error(`GitHub oracle failed with HTTP ${response.status}: ${JSON.stringify(body)}`); + return body; +} + +async function request(path, options = {}) { + const headers = { accept: "application/json", origin: baseUrl.origin }; + if (options.body) headers["content-type"] = "application/json"; + if (options.cookie) headers.cookie = options.cookie; + const response = await fetch(new URL(path, baseUrl), { + method: options.method || "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const body = await response.json().catch(() => ({})); + const expectedStatus = options.expectedStatus || 200; + if (response.status !== expectedStatus) { + throw new Error(`${options.method || "GET"} ${path} returned ${response.status}: ${JSON.stringify(body)}`); + } + return { response, headers: response.headers, body }; +} + +function required(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/orchestrator_prompt.md b/orchestrator_prompt.md index 33fb723..d15e910 100644 --- a/orchestrator_prompt.md +++ b/orchestrator_prompt.md @@ -3,6 +3,10 @@ Coordinate isolated agents to satisfy the authenticated caller goal. Do not do worker, ops, scout, or reviewer work yourself. +All framework paths in this prompt resolve under `$MULTIAGENT_FRAMEWORK_ROOT`. +Read policies, role modules, playbooks, and runbooks only from that image-owned +root; never use same-named files from the cloned application repository. + ## Start On a clean launch: @@ -44,13 +48,32 @@ bindings, independent review, and phase completion. - Spawn roles with `multiagent subagent spawn`; provider-native agents do not establish the required Linux identity or evidence boundary. -- Source changes follow `prompts/playbooks/implementation-lifecycle.md`. +- Source changes follow + `$MULTIAGENT_FRAMEWORK_ROOT/prompts/playbooks/implementation-lifecycle.md`. - External-only work skips the source lifecycle and uses reviewed ops requests. -- For ops, load only `prompts/playbooks/reviewed-ops-cycle.md`. Keep one ops - identity for the session and invoke `multiagent subagent reviewed-ops-cycle` +- For ops, load only + `$MULTIAGENT_FRAMEWORK_ROOT/prompts/playbooks/reviewed-ops-cycle.md`. Keep one + ops identity for the session and invoke `multiagent subagent reviewed-ops-cycle` for every immutable request. - Use a fresh reviewer for each immutable ops request. Finalize the ops identity only when operational work completes or reaches a blocker. +- `reviewed-ops-cycle` waits for both review and the ops continuation. Consume + its compact result directly: never call `subagent wait` afterward and never + inspect logs, transcripts, role homes, operation directories, or receipts to + rediscover its result. +- After a reviewed ops cycle, treat any required follow-up operation as + incomplete until the same ops identity has materialized its complete bound + request at `$MULTIAGENT_LOG_DIR/agents/OPS_NAME/request.json`. A prose proposal + or `awaiting` report is not a result; restore that ops identity, then run a new + reviewed cycle with a fresh reviewer. +- Complete successful external-only work with + `multiagent orchestrator complete --external-only`; do not enter source + lifecycle phases or write surrogate result files. +- Preserve literal predicates from the authenticated goal. When the caller + requires an empty list, zero records, or no submitted items, any returned item + disqualifies that candidate regardless of its subtype or state. Do not weaken + the predicate by reclassifying records; continue the same bounded search until + the exact predicate is proven or a concrete blocker is reached. - Load other playbooks only when their lifecycle is selected. Do not enumerate prompt files to discover known roles. diff --git a/prompts/playbooks/reviewed-ops-cycle.md b/prompts/playbooks/reviewed-ops-cycle.md index 5d6dd1d..ba6e107 100644 --- a/prompts/playbooks/reviewed-ops-cycle.md +++ b/prompts/playbooks/reviewed-ops-cycle.md @@ -16,28 +16,51 @@ Do not replace this identity after review. ## Review and continue -After ops returns a published artifact descriptor: +After ops returns its bound request path and digest lines, pass that exact +ops-owned file to the supervisor-owned cycle. For the standard role contract it +is `$MULTIAGENT_LOG_DIR/agents/OPS_NAME/request.json`: ```bash multiagent subagent reviewed-ops-cycle OPS_NAME \ - --request-file "$PUBLISHED_REQUEST_PATH" \ + --request-file "$MULTIAGENT_LOG_DIR/agents/OPS_NAME/request.json" \ --reviewer ops-reviewer-NN \ --timeout 900 ``` -Use a fresh reviewer name for each immutable request. This command: +Use a fresh reviewer name for each immutable request. The reviewer is required; +it is the independent authority boundary, not optional orchestration overhead. +This command: -1. publishes a safe legacy request when necessary; +1. validates that the bound request belongs to the named ops identity and + publishes it as a supervisor-owned immutable artifact; 2. binds the reviewer to the immutable request and exact runbook; 3. passes only a bounded artifact descriptor to the reviewer; 4. finalizes accepted review evidence before execution; and 5. continues the same ops identity in a fresh provider context with the exact - execute command. + execute command; and +6. waits for that continuation and prints one compact `ReviewedOpsCycleResult` + containing the ops conclusion or the next bound request. Do not reconstruct these mechanics manually. Prior panes, transcripts, final messages, and native provider resume state are intentionally excluded from the continuation boundary. -On rejection or preflight failure, report the blocker. A changed request needs a -new publication and reviewer. A review correction may use a fresh reviewer on -the same immutable request. Never create a second ops identity. +Never pass the supervisor-owned published artifact back as `--request-file`; +that path is intentionally outside the ops identity directory. On rejection or +preflight failure, report the blocker. A changed request needs a new publication +and reviewer. A review correction may use a fresh reviewer on the same immutable +request. Never create a second ops identity. + +The cycle already waits. Do not call `subagent wait` afterward, and do not read, +tail, grep, find, or list agent logs, transcripts, role homes, operation +directories, or receipts. Use only the returned `opsResult` and +`followUpRequest`. If `followUpRequest` is non-null, run a new cycle on that +exact path with a fresh reviewer. If it is null, use `opsResult` as the durable +conclusion. A prose proposal, an `awaiting` statement`, or a draft under a +private role-home path is incomplete work: restore the same ops identity to +materialize the canonical request rather than treating it as a result or +spawning a replacement. + +For an external-only task with successful reviewed operations and no source +changes, finish with `multiagent orchestrator complete --external-only`. Do not +manufacture source lifecycle phases or files. diff --git a/prompts/roles/ops-agent.md b/prompts/roles/ops-agent.md index b5504d4..31a3f7b 100644 --- a/prompts/roles/ops-agent.md +++ b/prompts/roles/ops-agent.md @@ -5,23 +5,46 @@ operations or encode provider-specific behavior in policy or source code. ## Prepare -1. Select the applicable runbook and derive the operation, target, parameters, - and phase from it and the prod-mcp target contract. -2. Write one bounded JSON draft under - `$MULTIAGENT_LOG_DIR/agents/$MULTIAGENT_SUBAGENT_NAME/` using the generic - envelope: `taskId`, `goal`, `operation`, `target`, `parameters`, and - `runbook`. Add `changeTicket` only when required. Never add `approvals` or - calculate `runbookContentSha256`. -3. Publish it with: +1. Select the applicable runbook and operation. Read the operation's live + prod-mcp contract before constructing parameters: ```bash -multiagent ops publish --draft-file "$DRAFT_FILE" \ +multiagent ops describe OPERATION_ID +``` + + Use the returned description, JSON schema, examples, and authorization + requirements exactly. Do not infer provider fields from public APIs, + repository source, validation failures, or prior operations. +2. Generate, then edit, one bounded JSON draft under + `$MULTIAGENT_LOG_DIR/agents/$MULTIAGENT_SUBAGENT_NAME/`: + +```bash +DRAFT_FILE="$MULTIAGENT_LOG_DIR/agents/$MULTIAGENT_SUBAGENT_NAME/request.json" +multiagent ops template > "$DRAFT_FILE" +``` + + Preserve the generated field shapes exactly. Set `taskId`, `goal`, + `operation.id`, `operation.version`, `parameters`, `runbook.id`, + `runbook.phase`, and `runbook.version` from the authenticated goal, + selected runbook, and the `ops describe` result. Do not add `target`; + runbook binding derives the canonical four-field target from the Markdown + runbook. Add `changeTicket` only when required. Never add `approvals`, + `runbookDocument`, or `runbookContentSha256`. +3. Bind the completed draft to a normalized path relative to the framework + root, then make the bound ops-owned request readable by the supervisor group + and not group-writable: + +```bash +multiagent ops bind-runbook --request-file "$DRAFT_FILE" \ --runbook-document runbooks/SELECTED.md +chmod 0640 "$DRAFT_FILE" ``` -Correct only the unpublished draft if validation fails. The returned descriptor -identifies the supervisor-owned immutable request; never copy or modify it. -Report that descriptor and stop for independent review. +Correct only this ops-owned request if binding fails. Report the exact +`DRAFT_FILE` path and the two digest lines returned by `bind-runbook`, then stop +for independent review. Do not call `ops publish`; the supervisor-owned +`reviewed-ops-cycle` publishes the immutable artifact after validating that the +bound request belongs to this ops identity. ## Execute after review @@ -33,8 +56,19 @@ multiagent ops execute --request-file PATH --reviewer REVIEWER_NAME Execute the reviewed request once. Interpret the structured outcome under the runbook and decide whether to finish, escalate, or prepare a distinct request. -Changed bytes always require a new review. Report the action ID, receipt path, -result, or exact blocker. +The command prints a compact result and persists the full receipt at +`receiptPath`. Use the compact result directly. Do not search logs, transcripts, +role homes, or operation directories, and do not reread the receipt unless the +compact result explicitly reports missing or truncated evidence. An +`operationId` or `actionId` returned by execution is evidence, not an operation +capability ID; never pass it to `ops describe`. +Changed bytes always require a new review. For every follow-up operation, rerun +`multiagent ops describe OPERATION_ID`, overwrite and bind the canonical +`$MULTIAGENT_LOG_DIR/agents/$MULTIAGENT_SUBAGENT_NAME/request.json`, run +`chmod 0640` on it, and report its exact path and digest lines. Never use a +private role-home path and never finish with only a proposed request. Report the +action ID, receipt path, final result, or exact blocker only when no follow-up +operation remains. Keep evidence in your trace directory. Credentials and signing authority remain with the supervisor and prod-mcp; missing credential environment variables are diff --git a/prompts/roles/ops-reviewer.md b/prompts/roles/ops-reviewer.md index a2f8010..42ae2f8 100644 --- a/prompts/roles/ops-reviewer.md +++ b/prompts/roles/ops-reviewer.md @@ -11,5 +11,8 @@ Before execution: - If `review-bind` fails for either schema or binding, reject the request. Manual digest calculation or visual comparison is not a substitute for successful deterministic validation. - If and only if the request matches the goal and runbook, make the first non-empty line exactly `Verdict: ACCEPTED`, then explain the decision without reproducing the binding artifact. - Otherwise make the first non-empty line `Verdict: REJECTED` and explain the deviation. +- Keep the explanation to at most three concise bullets. Do not restate the + request, runbook, schemas, digests, or evidence that the supervisor already + supplied. After execution, a separate reviewer invocation must inspect the persisted request and receipt under `MULTIAGENT_STATE_DIR/operations/ACTION_ID` and report any behavioral deviation or unexpected side effect. diff --git a/runbooks/github-repository-work.md b/runbooks/github-repository-work.md index ad9e0c6..d403cd2 100644 --- a/runbooks/github-repository-work.md +++ b/runbooks/github-repository-work.md @@ -6,6 +6,7 @@ - Version: `1.0.0` - Prod MCP operations: `github.read`, `github.clone`, `github.create-pr` - Operation version: `1.0.0` +- Set `target` to `{"cluster":"external-services","environment":"production","namespace":"github","service":"installation"}`. ## Goal @@ -16,8 +17,8 @@ pull request. GitHub credentials remain inside prod-mcp. ## Read phase 1. Set the phase to `read` and operation to `github.read`. -2. Identify the exact `owner/repository` and choose `get-repository`, `get-file`, `get-pull-request`, or `list-pull-requests`. -3. Bound file paths, refs, pull-request numbers, state, and result limits to the original goal. +2. Identify the exact `owner/repository` and choose `get-repository`, `get-file`, `get-pull-request`, `list-pull-requests`, or `list-pull-request-reviews`. +3. Bound file paths, refs, pull-request numbers, state, and result limits to the original goal. Compose multiple read requests when the goal requires correlating pull requests with their submitted reviews. 4. Persist the signed action ID and receipt. ## Materialize phase diff --git a/src/authority.rs b/src/authority.rs index 50a30b3..230c6a5 100644 --- a/src/authority.rs +++ b/src/authority.rs @@ -1,7 +1,7 @@ use crate::config; use serde::{Deserialize, Serialize}; -/// The complete mutation surface accepted by the authority supervisor. +/// The complete privileged surface accepted by the authority supervisor. /// /// CLI parsing happens before a request crosses the Unix socket. The server /// authorizes this enum instead of independently interpreting command strings, @@ -44,6 +44,8 @@ enum AuthorityOperation { ValidationLeaseShow, ValidationLeaseList, GateCheck, + OpsDescribe, + OpsPublishBound, OpsPublish, OpsExecute, } @@ -51,9 +53,18 @@ enum AuthorityOperation { impl AuthorityRequest { pub fn from_cli(command: &str, args: &[String]) -> Option { let (operation, forwarded) = match command { + // Typed workflow context is read-only and verifies the direct caller's + // kernel UID, so it must not be re-executed as the supervisor UID. + "workflow" if args.first().map(String::as_str) == Some("context") => return None, "workflow" => (AuthorityOperation::Workflow, args), "decision" => (AuthorityOperation::Decision, args), "dag" => (AuthorityOperation::Dag, args), + "ops" if args.first().map(String::as_str) == Some("describe") => { + (AuthorityOperation::OpsDescribe, &args[1..]) + } + "ops" if args.first().map(String::as_str) == Some("publish-bound") => { + (AuthorityOperation::OpsPublishBound, &args[1..]) + } "ops" if args.first().map(String::as_str) == Some("publish") => { (AuthorityOperation::OpsPublish, &args[1..]) } @@ -140,9 +151,12 @@ impl AuthorityRequest { | AuthorityOperation::TodoAssign | AuthorityOperation::TodoStatus | AuthorityOperation::GateCheck => uid == config::ORCHESTRATOR_UID, - AuthorityOperation::OpsPublish | AuthorityOperation::OpsExecute => { + AuthorityOperation::OpsDescribe + | AuthorityOperation::OpsPublish + | AuthorityOperation::OpsExecute => { uid == config::OPS_UID } + AuthorityOperation::OpsPublishBound => uid == config::ORCHESTRATOR_UID, AuthorityOperation::FindingCreate => uid == config::READER_UID, AuthorityOperation::FindingDismiss | AuthorityOperation::TodoClose => { matches!(uid, config::ORCHESTRATOR_UID | config::READER_UID) @@ -203,6 +217,8 @@ impl AuthorityRequest { AuthorityOperation::ValidationLeaseShow => ("subagent", Some("validation-lease-show")), AuthorityOperation::ValidationLeaseList => ("subagent", Some("validation-lease-list")), AuthorityOperation::GateCheck => ("subagent", Some("gate-check")), + AuthorityOperation::OpsDescribe => ("ops", Some("describe")), + AuthorityOperation::OpsPublishBound => ("ops", Some("publish-bound")), AuthorityOperation::OpsPublish => ("ops", Some("publish")), AuthorityOperation::OpsExecute => ("ops", Some("execute")), }; @@ -235,6 +251,7 @@ mod tests { #[test] fn typed_api_excludes_runtime_and_arbitrary_execution() { assert!(AuthorityRequest::from_cli("workflow", &strings(&["status"])).is_some()); + assert!(AuthorityRequest::from_cli("workflow", &strings(&["context", "workflow-1"])).is_none()); assert!(AuthorityRequest::from_cli("subagent", &strings(&["assignment-create"])).is_some()); assert!(AuthorityRequest::from_cli("agent", &strings(&["run"])).is_none()); assert!(AuthorityRequest::from_cli("role-exec", &[]).is_none()); @@ -263,6 +280,21 @@ mod tests { .expect("ops request"); assert!(ops.authorized_for(config::OPS_UID)); assert!(!ops.authorized_for(config::ORCHESTRATOR_UID)); + let describe = AuthorityRequest::from_cli("ops", &strings(&["describe", "github.read"])) + .expect("ops describe request"); + assert!(describe.authorized_for(config::OPS_UID)); + assert!(!describe.authorized_for(config::ORCHESTRATOR_UID)); + assert_eq!( + describe.into_cli(), + ("ops".to_string(), strings(&["describe", "github.read"])) + ); + let publish_bound = AuthorityRequest::from_cli( + "ops", + &strings(&["publish-bound", "--request-file", "/state/request.json"]), + ) + .expect("ops publish-bound request"); + assert!(publish_bound.authorized_for(config::ORCHESTRATOR_UID)); + assert!(!publish_bound.authorized_for(config::OPS_UID)); } #[test] diff --git a/src/prod_ops.rs b/src/prod_ops.rs index 4732a6b..73f0d04 100644 --- a/src/prod_ops.rs +++ b/src/prod_ops.rs @@ -14,6 +14,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; const MAX_OPERATION_REQUEST_BYTES: u64 = 65_536; const MAX_RUNBOOK_BYTES: u64 = 1_048_576; +const OPS_USAGE: &str = "usage:\n multiagent ops describe OPERATION_ID\n multiagent ops template\n multiagent ops bind-runbook --request-file PATH --runbook-document PATH\n multiagent ops publish --draft-file PATH --runbook-document PATH\n multiagent ops review-bind --request-file PATH\n multiagent ops execute --request-file PATH --reviewer NAME"; pub(crate) struct PublishedRequest { artifact_path: PathBuf, @@ -22,10 +23,6 @@ pub(crate) struct PublishedRequest { } impl PublishedRequest { - pub(crate) fn path(&self) -> &Path { - &self.artifact_path - } - pub(crate) fn descriptor_json(&self) -> Result { serde_json::to_string(&json!({ "artifactPath": self.artifact_path, @@ -47,12 +44,100 @@ struct TrustedApproval { pub fn run(args: &[String]) -> Result { match args.first().map(String::as_str) { + Some("describe") => describe(&args[1..]), + Some("template") => template(&args[1..]), Some("bind-runbook") => bind_runbook(&args[1..]), + Some("publish-bound") => publish_bound(&args[1..]), Some("publish") => publish(&args[1..]), Some("execute") => execute(&args[1..]), Some("review-bind") => review_bind(&args[1..]), - _ => Err("usage: multiagent ops bind-runbook --request-file PATH --runbook-document PATH | multiagent ops publish --draft-file PATH --runbook-document PATH | multiagent ops review-bind --request-file PATH | multiagent ops execute --request-file PATH --reviewer NAME".into()), + Some("help" | "--help" | "-h") => { + print_ops_help(); + Ok(ExitCode::SUCCESS) + } + _ => Err(OPS_USAGE.into()), + } +} + +fn describe(args: &[String]) -> Result { + if args.len() != 1 || args[0].is_empty() { + return Err("usage: multiagent ops describe OPERATION_ID".into()); + } + let response = call_prod_mcp_tool("operations_capabilities", json!({}))?; + let operation = operation_capability(&response, &args[0])?; + let mut compact = serde_json::Map::new(); + for key in [ + "id", + "version", + "description", + "access", + "allowedRunbooks", + "parameterSchema", + "parameterExamples", + "requireChangeTicket", + "requiredApprovalRoles", + ] { + if let Some(value) = operation.get(key) { + compact.insert(key.into(), value.clone()); + } } + println!( + "{}", + serde_json::to_string(&Value::Object(compact)) + .map_err(|error| format!("encode prod-mcp operation capability: {error}"))? + ); + Ok(ExitCode::SUCCESS) +} + +fn operation_capability<'a>(response: &'a Value, operation_id: &str) -> Result<&'a Value, String> { + let result = response + .get("result") + .and_then(Value::as_object) + .ok_or("prod-mcp capabilities response has no result object")?; + if result.get("isError").and_then(Value::as_bool) == Some(true) { + return Err(format!("prod-mcp capabilities failed: {}", Value::Object(result.clone()))); + } + let operations = result + .get("structuredContent") + .and_then(|value| value.get("operations")) + .and_then(Value::as_array) + .ok_or("prod-mcp capabilities response has no operations array")?; + operations + .iter() + .find(|operation| operation.get("id").and_then(Value::as_str) == Some(operation_id)) + .ok_or_else(|| format!("prod-mcp does not advertise operation {operation_id}")) +} + +fn template(args: &[String]) -> Result { + if !args.is_empty() { + return Err("usage: multiagent ops template".into()); + } + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "taskId": "replace-with-stable-task-id", + "goal": "replace with the bounded operation goal", + "operation": { + "id": "replace.with.operation-id", + "version": "1.0.0" + }, + "parameters": {}, + "runbook": { + "id": "replace.with-runbook-id", + "phase": "replace-with-runbook-phase", + "version": "1.0.0" + } + })) + .map_err(|error| format!("encode ops request template: {error}"))? + ); + Ok(ExitCode::SUCCESS) +} + +fn print_ops_help() { + println!("{OPS_USAGE}"); + println!( + "\nCall `multiagent ops describe OPERATION_ID` before constructing parameters; it returns prod-mcp's live description, JSON schema, examples, and authorization requirements.\n\nDraft schema:\n taskId: non-empty stable string\n goal: bounded goal copied from the authenticated task\n operation: object with id and semantic version\n parameters: exact provider operation parameters from `ops describe`\n runbook: object with id, phase, and semantic version\n\nGenerate a valid starting envelope with `multiagent ops template`, then bind it with a normalized framework-relative runbook path such as `runbooks/name.md`. After binding, run `chmod 0640 DRAFT_FILE` so the ops-owned request is supervisor-readable and not group-writable. The reviewed-ops-cycle publishes the immutable request. Do not supply target, approvals, runbookDocument, or runbookContentSha256 yourself." + ); } fn bind_runbook(args: &[String]) -> Result { @@ -91,6 +176,27 @@ fn publish(args: &[String]) -> Result { Ok(ExitCode::SUCCESS) } +#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] +fn publish_bound(args: &[String]) -> Result { + #[cfg(target_os = "linux")] + if unsafe { libc::geteuid() } != crate::config::SUPERVISOR_UID { + return Err("ops publish-bound is reserved for the authority supervisor".into()); + } + #[cfg(not(target_os = "linux"))] + return Err("ops publish-bound requires Linux".into()); + + #[cfg(target_os = "linux")] + { + let options = options(args)?; + let state = PathBuf::from(required_env("MULTIAGENT_STATE_DIR")?); + let request_file = PathBuf::from(required(&options, "--request-file")?); + let descriptor = publish_bound_request(&state, &request_file)?; + println!("{}", descriptor.descriptor_json()?); + Ok(ExitCode::SUCCESS) + } +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub(crate) fn publish_bound_request( state: &Path, request_file: &Path, @@ -507,9 +613,33 @@ fn execute(args: &[String]) -> Result { serde_json::to_vec_pretty(&result).map_err(|error| error.to_string())?, ) .map_err(|error| format!("persist operation receipt: {error}"))?; + let structured = result + .pointer("/result/structuredContent") + .cloned() + .unwrap_or(Value::Null); + let evidence = structured + .get("summary") + .and_then(Value::as_str) + .map(|summary| { + serde_json::from_str(summary).unwrap_or_else(|_| Value::String(summary.into())) + }) + .unwrap_or(Value::Null); + let compact = json!({ + "apiVersion": "multiagent.moveindustries.io/v1", + "kind": "OperationExecutionResult", + "actionId": action_id, + "operationId": structured.get("operationId").cloned().unwrap_or(Value::Null), + "requestedOperation": structured.get("requestedOperation").cloned().unwrap_or(Value::Null), + "state": structured.get("state").cloned().unwrap_or(Value::Null), + "outcome": structured.get("outcome").cloned().unwrap_or(Value::Null), + "code": structured.get("code").cloned().unwrap_or(Value::Null), + "message": structured.get("message").cloned().unwrap_or(Value::Null), + "evidence": evidence, + "receiptPath": operation_dir.join("receipt.json"), + }); println!( "{}", - serde_json::to_string_pretty(&result).map_err(|error| error.to_string())? + serde_json::to_string(&compact).map_err(|error| error.to_string())? ); Ok(ExitCode::SUCCESS) } @@ -997,7 +1127,7 @@ fn exact_runbook_bytes(relative: &str) -> Result, String> { .components() .any(|component| !matches!(component, std::path::Component::Normal(_))) { - return Err("runbookDocument must be a normalized relative path".into()); + return Err("runbookDocument must be a normalized path relative to MULTIAGENT_FRAMEWORK_ROOT, for example runbooks/name.md".into()); } let framework_root = fs::canonicalize(required_env("MULTIAGENT_FRAMEWORK_ROOT")?) .map_err(|error| format!("resolve multiagent framework root: {error}"))?; @@ -1073,6 +1203,10 @@ fn sign_permit(payload: &[u8]) -> Result { } fn call_prod_mcp(permit: &str) -> Result { + call_prod_mcp_tool("operations_execute", json!({"permit": permit})) +} + +fn call_prod_mcp_tool(name: &str, arguments: Value) -> Result { let url = required_env("PROD_MCP_URL")?; if !(url.starts_with("http://") || url.starts_with("https://")) { return Err("PROD_MCP_URL must use HTTP or HTTPS".into()); @@ -1084,7 +1218,7 @@ fn call_prod_mcp(permit: &str) -> Result { .map_err(|error| format!("create prod-mcp temporary directory: {error}"))?; let request_headers = private_temp_path(&temporary_dir, "prod-mcp-request-headers", "txt")?; let response_headers = private_temp_path(&temporary_dir, "prod-mcp-response-headers", "txt")?; - let call = json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"arguments":{"permit":permit},"name":"operations_execute"}}); + let call = json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"arguments":arguments,"name":name}}); let result = (|| { write_mcp_headers(&request_headers, &token, None)?; write_private_file(&response_headers, b"")?; @@ -1094,7 +1228,7 @@ fn call_prod_mcp(permit: &str) -> Result { let _ = fs::remove_file(response_headers); let result = result?; if let Some(error) = result.get("error") { - return Err(format!("prod-mcp execution failed: {error}")); + return Err(format!("prod-mcp tool {name} failed: {error}")); } Ok(result) } @@ -1416,7 +1550,7 @@ fn base64_decode(value: &str) -> Result, String> { mod tests { use super::{ base64_decode, base64url_encode, build_request, canonical, curl_command, ecdsa_der_to_raw, - parse_mcp_body, private_temp_path, review_binding_marker, review_binding_matches, + operation_capability, parse_mcp_body, private_temp_path, review_binding_marker, review_binding_matches, review_binding_value, review_evidence_is_bound, reviewer_accepted, runbook_content_digest, validate_request_template, write_mcp_headers, TrustedApproval, @@ -1574,6 +1708,27 @@ mod tests { ); } + #[test] + fn operation_capability_selects_the_exact_live_contract() { + let response = json!({ + "result": { + "structuredContent": { + "operations": [ + {"id":"github.read","parameterSchema":{"type":"object"}}, + {"id":"slack.read","parameterSchema":{"type":"object"}} + ] + } + } + }); + let operation = operation_capability(&response, "github.read").unwrap(); + assert_eq!(operation["id"], "github.read"); + assert_eq!(operation["parameterSchema"]["type"], "object"); + assert_eq!( + operation_capability(&response, "github.write").unwrap_err(), + "prod-mcp does not advertise operation github.write" + ); + } + #[test] fn shared_action_permit_fixture_matches_the_rust_contract() { let fixture: serde_json::Value = diff --git a/src/runtime.rs b/src/runtime.rs index 5bbf5a9..5ca8ec0 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1023,16 +1023,23 @@ pub fn orchestrator(args: &[String]) -> Result { .iter() .any(|arg| matches!(arg.as_str(), "-h" | "--help")) { - println!("Usage:\n multiagent orchestrator complete\n\nRuns the normal-path completion gates for the active orchestrated workflow."); + println!("Usage:\n multiagent orchestrator complete [--external-only]\n\nRuns the supervisor completion gates. Use --external-only only for reviewed operations with no source implementation lifecycle."); return Ok(ExitCode::SUCCESS); } - if args != ["complete"] { + if args.first().map(String::as_str) != Some("complete") + || args.len() > 2 + || (args.len() == 2 && args[1] != "--external-only") + { return Err(format!("unknown command: {}", args[0])); } if config::lifecycle_enforced() { let workflow_id = env_nonempty("MULTIAGENT_WORKFLOW_ID") .ok_or_else(|| "lifecycle enforcement requires MULTIAGENT_WORKFLOW_ID".to_string())?; - let diff = crate::workflow::supervisor_complete(&workflow_id)?; + let diff = if args.get(1).map(String::as_str) == Some("--external-only") { + crate::workflow::supervisor_complete_external(&workflow_id)? + } else { + crate::workflow::supervisor_complete(&workflow_id)? + }; println!("workflow completed\t{workflow_id}\t{diff}\tauthority=supervisor"); } else { run_self_quiet(&["subagent", "gate-check"])?; @@ -1761,11 +1768,12 @@ fn reviewed_ops_reviewer_instruction( ) } -fn reviewed_ops_execute_instruction(request_file: &Path, reviewer: &str) -> String { +fn reviewed_ops_execute_instruction(request_file: &Path, reviewer: &str, ops_name: &str) -> String { format!( - "Continue the same runbook with the independently reviewed immutable request. Execute exactly:\n\nmultiagent ops execute --request-file {} --reviewer {}\n\nInspect the persisted structured outcome and decide from the runbook whether to stop, escalate, or propose another distinct reviewed operation. Never execute the same immutable request twice. Report the result or exact blocker. Do not create a replacement ops identity.", + "Continue the same runbook with the independently reviewed immutable request. Execute exactly:\n\nmultiagent ops execute --request-file {} --reviewer {}\n\nUse the compact execution result printed by that command; the full receipt is already persisted at its receiptPath. Do not inspect agent logs, transcripts, operation directories, or the receipt unless the compact result explicitly reports missing or truncated evidence. Decide from the runbook whether to stop, escalate, or prepare another distinct reviewed operation. Never execute the same immutable request twice and never run `ops describe` on the returned operationId or actionId. If another operation is needed, first run `multiagent ops describe OPERATION_ID`, then materialize and bind the complete next request at exactly `$MULTIAGENT_LOG_DIR/agents/{}/request.json`, run `chmod 0640` on it, and report that exact path plus the two digest lines. Do not use a role-home path, do not call `ops publish`, and do not finish with only a proposed request. If no operation remains, report the final result or exact blocker. Do not create a replacement ops identity.", shell_escape(&request_file.display().to_string()), - shell_escape(reviewer) + shell_escape(reviewer), + ops_name, ) } @@ -1824,13 +1832,17 @@ fn reviewed_ops_cycle(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String .map_err(io_error("resolve ops agent log directory"))?; if !request_file.starts_with(&ops_logs) { return Err(format!( - "reviewed ops request must belong to ops identity {ops_name}" + "reviewed ops request must be the ops-owned bound request under {}; do not pass the supervisor-owned published artifact", + ops_logs.display() )); } - let published = crate::prod_ops::publish_bound_request(&cfg.state, &request_file)?; - let request_file = published.path(); - let descriptor = published.descriptor_json()?; - let binding = crate::prod_ops::review_binding_for_request(request_file)?; + let reviewed_request_sha256 = format!( + "sha256:{:x}", + Sha256::digest(fs::read(&request_file).map_err(io_error("read reviewed ops request"))?) + ); + let ops_request_file = request_file; + let (request_file, descriptor) = publish_reviewed_ops_request(&ops_request_file)?; + let binding = crate::prod_ops::review_binding_for_request(&request_file)?; let reviewer_instruction = reviewed_ops_reviewer_instruction(&request_file, &descriptor, &binding); spawn( @@ -1838,7 +1850,7 @@ fn reviewed_ops_cycle(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String &[ reviewer.clone(), "--role".into(), - "ops-reviewer".into(), + "reviewer".into(), "--instruction".into(), reviewer_instruction, ], @@ -1855,9 +1867,9 @@ fn reviewed_ops_cycle(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String )); } finalize(cfg, std::slice::from_ref(&reviewer))?; - crate::prod_ops::preflight_reviewed_request(request_file, &reviewer)?; + crate::prod_ops::preflight_reviewed_request(&request_file, &reviewer)?; - let execute_instruction = reviewed_ops_execute_instruction(request_file, &reviewer); + let execute_instruction = reviewed_ops_execute_instruction(&request_file, &reviewer, ops_name); restore( cfg, &[ @@ -1869,9 +1881,61 @@ fn reviewed_ops_cycle(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String ], )?; wait(cfg, &[ops_name.to_string(), "--timeout".into(), timeout])?; + let ops_status = read_trimmed(&ops_dir.join("status")).unwrap_or_else(|| "unknown".into()); + let ops_result = fs::read_to_string(ops_dir.join("last-message.txt")) + .unwrap_or_else(|_| "ops agent produced no durable final message".into()); + let ops_result: String = ops_result.chars().take(16_384).collect(); + let follow_up_request = fs::read(&ops_request_file).ok().and_then(|bytes| { + let sha256 = format!("sha256:{:x}", Sha256::digest(&bytes)); + (sha256 != reviewed_request_sha256).then(|| { + serde_json::json!({ + "path": ops_request_file.display().to_string(), + "sha256": sha256, + "bytes": bytes.len(), + }) + }) + }); + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "apiVersion": "multiagent.moveindustries.io/v1", + "kind": "ReviewedOpsCycleResult", + "opsName": ops_name, + "reviewer": reviewer, + "opsStatus": ops_status, + "cycleWaitedForCompletion": true, + "additionalWaitRequired": false, + "opsResult": ops_result, + "followUpRequest": follow_up_request, + })) + .map_err(|error| format!("encode reviewed ops cycle result: {error}"))? + ); Ok(()) } +fn publish_reviewed_ops_request(request_file: &Path) -> Result<(PathBuf, String), String> { + let request_file = request_file + .to_str() + .ok_or("reviewed ops request path is not valid UTF-8")?; + let output = run_self_output(&[ + "ops", + "publish-bound", + "--request-file", + request_file, + ])?; + let descriptor = String::from_utf8(output.stdout) + .map_err(|error| format!("decode published ops request descriptor: {error}"))?; + let descriptor = descriptor.trim().to_string(); + let value: serde_json::Value = serde_json::from_str(&descriptor) + .map_err(|error| format!("decode published ops request descriptor: {error}"))?; + let artifact_path = value + .get("artifactPath") + .and_then(serde_json::Value::as_str) + .filter(|path| !path.is_empty()) + .ok_or("published ops request descriptor has no artifactPath")?; + Ok((PathBuf::from(artifact_path), descriptor)) +} + fn list_subagents(cfg: &RuntimeConfig, args: &[String]) -> Result<(), String> { if !args.is_empty() { return Err("list takes no arguments".into()); @@ -4168,12 +4232,14 @@ mod tests { assert!(!review.contains("Slack")); assert!(!review.contains("Grafana")); - let execute = reviewed_ops_execute_instruction(request, "ops-reviewer-01"); + let execute = reviewed_ops_execute_instruction(request, "ops-reviewer-01", "github-ops"); assert!(execute.contains( "multiagent ops execute --request-file /state/logs/agents/ops-primary/request.json --reviewer ops-reviewer-01" )); assert!(execute.contains("decide from the runbook")); assert!(execute.contains("Never execute the same immutable request twice")); + assert!(execute.contains("compact execution result")); + assert!(execute.contains("never run `ops describe` on the returned operationId or actionId")); } #[cfg(unix)] diff --git a/src/workflow.rs b/src/workflow.rs index deca437..970dbfa 100644 --- a/src/workflow.rs +++ b/src/workflow.rs @@ -1231,6 +1231,114 @@ pub fn supervisor_complete(id: &str) -> Result { Ok(diff) } +/// Seals a workflow that intentionally performed only independently reviewed +/// external operations and never entered the source implementation lifecycle. +pub fn supervisor_complete_external(id: &str) -> Result { + if config::lifecycle_enforced() + && std::env::var("MULTIAGENT_UID_SANDBOX").as_deref() == Ok("1") + && std::env::var("MULTIAGENT_AUTHORITY_SERVER_CHILD").as_deref() != Ok("1") + { + return Err("lifecycle completion must execute inside the authority supervisor".into()); + } + let store = Store::configured()?; + let p = store.paths(id)?; + let _lock = store.lock(&p)?; + let mut state = read_env(&p.state, id)?; + if state_value(&state, "phase") != "pre-implementation" { + return Err(format!( + "external-only completion requires phase=pre-implementation, got {}", + state_value(&state, "phase") + )); + } + if !state_value(&state, "preimplementation_gate").is_empty() + || !state_value(&state, "decision_id").is_empty() + || !state_value(&state, "candidate_diff_hash").is_empty() + { + return Err("external-only completion cannot bypass a started source implementation lifecycle".into()); + } + validate_original_task(&state)?; + let todos = read_todos(&p.todos)?; + let active_rows: Vec<&str> = todos + .iter() + .filter(|row| active(row.get(4))) + .map(|row| row.get(0)) + .collect(); + if !active_rows.is_empty() { + return Err(format!( + "external-only completion blocked by active TODOs: {}", + active_rows.join(",") + )); + } + let operations_dir = store.state_dir.join("operations"); + let mut successful_operations = 0usize; + if operations_dir.is_dir() { + for entry in fs::read_dir(&operations_dir) + .map_err(|error| format!("list external operation receipts: {error}"))? + { + let entry = entry + .map_err(|error| format!("read external operation receipt entry: {error}"))?; + let receipt_path = entry.path().join("receipt.json"); + if !receipt_path.is_file() { + continue; + } + let receipt: serde_json::Value = serde_json::from_slice( + &fs::read(&receipt_path) + .map_err(|error| format!("read external operation receipt: {error}"))?, + ) + .map_err(|error| { + format!( + "decode external operation receipt {}: {error}", + receipt_path.display() + ) + })?; + let structured = receipt + .pointer("/result/structuredContent") + .unwrap_or(&serde_json::Value::Null); + let succeeded = structured + .get("state") + .and_then(serde_json::Value::as_str) + == Some("succeeded") + && structured + .pointer("/outcome/disposition") + .and_then(serde_json::Value::as_str) + == Some("succeeded") + && structured + .pointer("/outcome/terminal") + .and_then(serde_json::Value::as_bool) + == Some(true); + if !succeeded { + return Err(format!( + "external-only completion requires successful terminal receipts; {} is not successful", + receipt_path.display() + )); + } + successful_operations += 1; + } + } + if successful_operations == 0 { + return Err( + "external-only completion requires at least one successful reviewed operation receipt" + .into(), + ); + } + crate::subagent::completion_gate_check()?; + let result = format!("external-only:{successful_operations}"); + state.insert("phase".into(), "complete".into()); + state.insert("candidate_diff_hash".into(), result.clone()); + state.insert("reviewed_diff_hash".into(), result.clone()); + state.insert("updated_at".into(), timestamp()); + write_env(&p.state, &state)?; + event( + &p.events, + "phase_transitioned", + &format!( + "from=pre-implementation\tto=complete\titeration={}\tauthority=supervisor\troute=external-only\toperations={successful_operations}", + state_value(&state, "iteration") + ), + )?; + Ok(result) +} + fn value(args: &[String]) -> Result<(), String> { if args.len() != 2 { return Err("value requires WORKFLOW_ID KEY".into());