AIWG Executor Contract Integration

AIWG Executor Contract Integration

The management server can register itself as an executor with an external `aiwg serve` instance, accepting mission dispatches and reporting back over a typed event stream. This is the agentic-sandbox side of the AIWG executor contract (`executor.v1.md`).

When configured, `aiwg serve` can:

  • Discover this sandbox via `POST /api/v1/executors/register`
  • Route missions to it via `POST /api/v1/sessions/:id/dispatch`
  • Watch the mission lifecycle over a WS stream at `/ws/executors/{id}`
  • Survive both ends restarting (`mission.suspended` → `mission.resumed`)

Sandbox registration (the older `POST /api/sandboxes/register` path) and executor registration are independent and run in parallel — the same sandbox identity is registered as both, sharing the `instance_id`.


Configuration

Env varRequiredDefaultPurpose
`AIWG_SERVE_ENDPOINT`No (integration disabled if absent)HTTP base URL of `aiwg serve`, e.g. `http://localhost:7337`
`AIWG_SERVE_NAME`No`agentic-sandbox`Display name shown in the AIWG dashboard

When `AIWG_SERVE_ENDPOINT` is unset the integration is silently disabled and `/api/v1/aiwg/status` reports `configured: false`.

Files on disk

PathPurpose
`<secrets_dir>/../identity`Persistent sandbox `instance_id` (UUID v7) — reused as `executor_id`.
`<secrets_dir>/../missions.json`In-flight mission records (`MissionStore` persistence). Written atomically (`tmp + rename`) on every state change. After a restart, `executor.resync` reports the loaded `owned_mission_ids` so AIWG reconciles instead of dropping in-flight work.

Registration

On startup, the sandbox calls both registration endpoints on `aiwg serve`:

1. `POST /api/sandboxes/register` (existing sandbox path) 2. `POST /api/v1/executors/register` (executor contract — added in #193)

Executor registration payload:

{
  "executor_id":  "<sandbox instance_id>",
  "name":         "agentic-sandbox-<sandbox_name>",
  "version":      "<management server crate version>",
  "spec_version": "1.0.0",
  "transport_endpoints": {
    "rest": "http://<host>:8122",
    "ws":   "ws://<host>:8121"
  },
  "capabilities": [
    "isolation:vm",
    "isolation:container",
    "isolation:host",
    "runtime:claude-code",
    "platform:linux/x64",
    "resumable",
    "hitl"
  ]
}

Response: `{ "executor_id": "...", "token": "<bearer>" }`.

The bearer token is stored in memory and used for two things:

  • Validating inbound `POST /api/v1/sessions/:id/dispatch` requests
  • Authenticating the outbound `/ws/executors/{id}` connection (passed as `?token=...`)

If executor registration returns 404 (route not yet implemented on the AIWG side) or any other error, the sandbox logs a warning and continues — sandbox registration is independent and stays operational. `/api/v1/aiwg/status` exposes both `executor_id` and `executor_register_error` so the dashboard can show partial state.

Capability vocabulary

CapabilityMeaning
`isolation:vm`Can execute missions inside KVM/QEMU VMs
`isolation:container`Can execute missions inside Docker containers
`isolation:host`Can execute missions directly on the local host through a configured host supervisor. This is the least-isolated tier and grants full host access to the launched process.
`runtime:claude-code`Hosts the Claude Code agent runtime
`platform:linux/x64`Linux on x86-64 host
`resumable`Mission state survives mgmt-server restarts (via `missions.json`)
`hitl`Supports human-in-the-loop pause/resume round-trip

Dispatch

`POST /api/v1/sessions/:id/dispatch`

Called by `aiwg serve` to route a mission to this sandbox.

Auth: `Authorization: Bearer <token>` — the token issued at executor registration. Validated via constant-time comparison after a length-prefix check.

Request body:

{
  "mission_id":  "<UUID>",
  "objective":   "<command/prompt to run>",
  "completion":  "<optional completion criteria text>",
  "long_running": false,
  "executor_filter": {
    "executor_id":  null,
    "capabilities": [],
    "agent_id":     "agent-01"
  },
  "metadata": { "issue": 1234, "session_id": "..." }
}

`executor_filter` precedence:

  • Explicit `agent_id` hint → that agent (404 if not connected)
  • Otherwise → first available agent (operator default)
  • `executor_id` and `capabilities` filters are honoured by the AIWG router

before reaching the sandbox

Response: `202 Accepted`

{
  "mission_id":      "<echo>",
  "executor_id":     "<sandbox instance_id>",
  "status":          "assigned",
  "estimated_start": "2026-05-09T07:13:22.123Z"
}

Failure responses:

StatusWhen
`401 Unauthorized`Missing or invalid bearer token
`404 Not Found``executor_filter.agent_id` references a non-connected agent
`503 Service Unavailable`aiwg integration not configured, executor not registered, no agents connected, or mission store unavailable
`500 Internal Server Error`Dispatcher failure (mission state → `Failed`, `mission.failed` event emitted)

On success, the sandbox immediately: 1. Inserts a `MissionRecord` (state = `Assigned`) 2. Emits `mission.assigned` over the executor WS 3. Calls the existing dispatcher to start a Background session with the `objective` as the command 4. Binds the resulting `pty_session_id` to the mission so SessionStart and SessionEnd events emit the corresponding `mission.started` / `mission.completed` / `mission.failed`

`DELETE /api/v1/executors/:executor_id`

Called by the sandbox on clean shutdown. Authenticated with the same bearer token.


Executor WS Stream

The sandbox opens a persistent outbound WS connection to:

ws://<aiwg-serve>/ws/executors/{executor_id}?token=<bearer>

This stream is bidirectional:

  • Sandbox → AIWG: `mission.*` lifecycle events + `executor.resync`
  • AIWG → Sandbox: inbound events such as `mission.hitl_responded`

It runs in parallel with the existing `/ws/sandbox/{sandbox_id}` stream (which carries `SandboxEvent`s); failures on one don't stall the other. Reconnects use exponential backoff (1 s → 30 s, capped).

Event envelope

All sandbox-emitted events use this shape:

{
  "event":       "mission.started",
  "executor_id": "<sandbox instance_id>",
  "mission_id":  "<UUID, omitted only on executor.resync>",
  "ts":          "2026-05-09T07:13:22.123Z",
  "data":        { /* per-event-type payload */ }
}

`ts` is RFC 3339 with millisecond precision (UTC).

Event reference

Mission lifecycle (sandbox → AIWG)

EventWhen`data` shape
`mission.assigned`Dispatch accepted, before agent session starts`{ "state": "assigned", "estimated_start": "<RFC3339>" }`
`mission.started`Agent session begins inside VM/container`{ "state": "running", "agent_runtime": "claude-code", "pty_session_id": "<id>" }`
`mission.progress`(Reserved — emitter exists, not yet wired to a trigger)`{ "phase": "execution", "summary": "...", "iteration": N }`
`mission.hitl_required`Agent paused awaiting human input`{ "hitl_id": "...", "prompt": "...", "context": "..." }`
`mission.suspended`SIGTERM/SIGINT received before clean exit`{ "state": "suspended", "checkpoint_id": "...", "reason": "mgmt_server_shutdown" }`
`mission.reconnected`Per-mission emitted right after `executor.resync` on every WS reconnect`{ "checkpoint_id": "..." }`
`mission.resumed`Follows `mission.reconnected` to declare the mission running again`{ "state": "running", "resumed_from": "suspended" }`
`mission.completed`Session ended with exit code 0 (or unknown)`{ "state": "done", "exit_code": 0, "summary": "..." }`
`mission.failed`Session ended with non-zero exit, or dispatcher error`{ "state": "failed", "reason": "non_zero_exit", "error": "...", "exit_code": <int> }`
`mission.aborted`Operator-initiated kill via `kill_session``{ "state": "aborted", "aborted_by": "operator", "reason": "..." }`

Executor-level (sandbox → AIWG)

EventWhen`data` shape
`executor.resync`Sent as the first frame on every WS connect`{ "owned_mission_ids": ["<id>", ...], "protocol_version": "1.0.0" }`

After `executor.resync`, the sandbox emits a `mission.reconnected` + `mission.resumed` pair for each mission in `owned_mission_ids`.

Inbound (AIWG → sandbox)

EventWhenRequired `data` fields
`mission.hitl_responded`Operator answered a HITL prompt in the AIWG dashboard`{ "hitl_id": "<id>", "text": "<response>" }`

The sandbox resolves `hitl_id` via the existing `HitlStore`, then injects `text + "\ "` into the agent's PTY stdin via `dispatcher.send_stdin()` — the same path as the local `POST /api/v1/hitl/:id/respond` endpoint.


Mission state machine

`MissionState` (defined in `management/src/aiwg_serve.rs`):

Assigned ──┬─→ Running ──┬─→ HitlRequired ──┐
           │             │                  │
           │             │   ┌──────────────┘
           │             │   ▼
           │             ├─→ Suspended ─────→ Running (after restart + resync)
           │             │
           │             └─→ Completed │ Failed │ Aborted   (terminal)
           │
           └─→ Failed (dispatcher couldn't start the session)

Terminal states (`Completed` / `Failed` / `Aborted`) are excluded from `active_mission_ids()` so they don't appear in `executor.resync`.


Graceful shutdown lifecycle

On `SIGTERM` or `SIGINT`, the management server:

1. Walks `MissionStore.active_mission_ids()` 2. Emits `mission.suspended` for each non-terminal mission with reason `"mgmt_server_shutdown"` 3. Updates each record's state to `Suspended` (persisted to `missions.json`) 4. Sleeps 250 ms to let the WS forwarder push the events out 5. `process::exit(0)`

On the next start: 1. `MissionStore::load_or_default()` reads `missions.json` 2. Sandbox + executor re-register with `aiwg serve` 3. `executor_ws_loop` opens the new WS connection 4. Sends `executor.resync { owned_mission_ids: [<survivors>] }` 5. For each survivor: `mission.reconnected` then `mission.resumed` 6. Bumps each survivor's state back to `Running`

The full lifecycle:

dispatch → assigned → started → ... → SIGTERM → suspended
                                        ↓ (operator restarts mgmt)
start → load store → reconnect WS → reconnected → resumed → ...

Status & observability

  • `GET /api/v1/aiwg/status` returns the connection state including

`executor_id`, `executor_register_error`, and `connected`. Bearer tokens are never included in the JSON response.

  • Mutations to `MissionStore` are atomic (`tmp + rename`); a partial write

cannot corrupt the file.

  • WS reconnect can be triggered manually via `POST /api/v1/aiwg/reconnect`,

which skips the backoff sleep.


Known limitations

  • `mission.progress` is reserved in the wire vocabulary but not yet wired to

an emitter. The natural trigger (per-iteration update from the agent runtime) needs richer signal than the current dispatcher exposes.

  • Resumability is local-only: persistence file lives on the host running

agentic-mgmt. Cross-host migration is out of scope for v1.

  • Bearer-token rotation requires re-registration. The token is fixed for

the lifetime of an executor registration cycle.


References

`management/src/dispatch/dispatcher.rs`, `management/src/hitl.rs`, `management/src/main.rs`