Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m40s
Project CI / Native shell tests (pull_request) Successful in 6m11s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / Repository checks (pull_request) Successful in 2m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m44s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m38s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-24 19:43:20 +08:00
99 changed files with 10278 additions and 1925 deletions
@@ -5,131 +5,42 @@ description: Guide use of Genarrative's hosted external editor/canvas MCP or asy
# Genarrative External Editor API
Discover the live integration through `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. Treat `GET https://www.genarrative.world/api/external/v1/openapi.json` as the field-level source of truth. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`.
Use Genarrative to manage canvas projects and asset-library records, generate images, character animations, videos, and audio, and edit images. Discover the hosted integration at `https://www.genarrative.world/api/external/v1/agent-integration.json`.
Prefer the hosted Streamable HTTP MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. It exposes the External v1 operations as tools and the Skill documentation as resources; it does not require a local MCP server. Use this complete Skill package when remote MCP is unavailable or local-file upload needs client-side orchestration.
Connect to `https://www.genarrative.world/api/external/v1/mcp` using Streamable HTTP and a Bearer API Key. Read tool descriptions and input schemas from `tools/list`; read the documents below through `resources/read` when needed. The hosted service needs no local MCP server. For direct REST, use the live `/api/external/v1/openapi.json` contract (in this repository: `docs/openapi/genarrative-external-v1.openapi.json`).
Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses only Python stdlib, reads the local private API Key file, keeps the production base URL fixed, uploads local references, and wraps asynchronous submission, polling, and result retrieval.
## Working with the Service
## Workflow
1. Discover the integration manifest. Choose hosted MCP when supported; otherwise use the helper or direct REST.
2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec.
3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints.
4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts.
5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to operations that accept object references; never substitute a temporary signed URL. For an icon-spritesheet primary spec, additionally create a project resource or asset record with `assetKind="icon-spec"`, then pass the returned resource or asset ID as `referenceId`.
6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder.
7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`.
8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request.
9. Reload the normal project or asset-library read endpoint when the caller needs complete authoritative state. Generation results are intentionally compact.
10. Stay within `/api/external/v1`. Never call internal workers, queues, admin/profile APIs, or SpacetimeDB endpoints unless the user explicitly changes scope.
## Essential Invariants
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
- Select tools by the requested outcome. Use `find_canvas_projects` and `find_assets` to locate existing context; create projects or folders only when the task needs them. A folder need not have the same name as the project.
- For generation, specify project, library, and `canvasCompletion` fields only as supported by the selected tool and needed for the requested destination. Do not duplicate records already created by generation.
- Upload local references using `prepare_asset_upload`: request a ticket, transfer the file from the client, then confirm the object. Confirmation does not create a canvas layer or a project/library record. Use the reference type accepted by the target tool; some operations require a registered resource or asset ID rather than an object key.
- Generation is paid and asynchronous. Keep one stable `idempotencyKey` per logical generation and retain the returned `operationId`. Call `check_generation` according to `pollAfterMs`; consume `result` only after `completed`, and report the safe error on `failed`. A polling timeout does not justify another generation.
- Read actual artifacts and warnings before claiming the requested deliverable is complete. Use project/library reads for complete persisted records, and `find_assets` with `action=get_download_url` for temporary media access.
- Keep API Keys and temporary upload/download credentials out of chat, repository files, and logs. Business calls operate within the API Key's owner and scopes.
## Documentation Navigation
Read only the references needed for the task, but always verify exact schemas and enums against live OpenAPI:
Read the reference relevant to the current operation; exact input fields and enums come from the tool schema or OpenAPI.
- `references/capability-routing.md`: read before selecting an MCP tool or REST operation, creating a canvas session, or working in the AI game creator visual DAG.
- `references/api-operations.md`: read when constructing project, canvas, asset-library, upload, generation, or generation-status calls.
- `references/authentication-and-safety.md`: read before handling credentials, local files, OSS form upload, retries, private media, or logs.
- `references/requests-and-outputs.md`: read before building generation payloads, polling, interpreting compact results, applying canvas completion, or handling post-processing warnings.
| Need | Reference | MCP resource URI |
| --- | --- | --- |
| Choose tools and actions by user intent | [Capability routing](references/capability-routing.md) | `genarrative://external-editor/skill/references/capability-routing.md` |
| Map tool calls to REST operations | [API operations](references/api-operations.md) | `genarrative://external-editor/skill/references/api-operations.md` |
| Configure credentials, upload files, handle retries and deletion | [Authentication and safety](references/authentication-and-safety.md) | `genarrative://external-editor/skill/references/authentication-and-safety.md` |
| Construct requests, poll results, place media, handle warnings | [Requests and outputs](references/requests-and-outputs.md) | `genarrative://external-editor/skill/references/requests-and-outputs.md` |
The hosted MCP exposes the same documents through:
`genarrative://external-editor/usage` contains the short service instructions; `genarrative://external-editor/openapi` contains the REST contract. This entry is available at `genarrative://external-editor/skill`. Reading a resource does not install the downloadable Skill or its Python helper.
- `genarrative://external-editor/skill`
- `genarrative://external-editor/skill/references/capability-routing.md`
- `genarrative://external-editor/skill/references/api-operations.md`
- `genarrative://external-editor/skill/references/authentication-and-safety.md`
- `genarrative://external-editor/skill/references/requests-and-outputs.md`
- `genarrative://external-editor/openapi`
## Direct REST and Local Helpers
## Hosted Integration Discovery
When remote MCP is unavailable or local-file orchestration needs a helper, the complete package is available at `GET /api/external/v1/skill.zip`; the raw entry is at `GET /api/external/v1/skill/SKILL.md`. Verify the archive SHA-256 against the integration manifest before installing. The archive includes this entry, four references, `scripts/genarrative_external_api.py`, and `agents/openai.yaml`. Discovery and documentation downloads are public; MCP and business calls require authentication.
- Manifest: `GET /api/external/v1/agent-integration.json`.
- Hosted MCP: `POST /api/external/v1/mcp`, Streamable HTTP, same Bearer API Key.
- OpenAPI: `GET /api/external/v1/openapi.json`.
- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`.
- Complete Skill archive: `GET /api/external/v1/skill.zip`.
The archive contains this main file, four one-level references, the Python helper, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing. Discovery, OpenAPI, and Skill downloads are public; MCP and business operations require authentication.
## Python Helper
Store the API Key outside the repository at `~/.config/genarrative/external-editor-api.json`:
```json
{
"apiKey": "tnr_sk_..."
}
```
Set restrictive permissions where possible, then smoke-test without printing the key:
The Python stdlib helper reads the private API Key file described in [authentication and safety](references/authentication-and-safety.md) and uses the production base URL. For a read-only smoke test:
```bash
chmod 600 ~/.config/genarrative/external-editor-api.json
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
```
For a canvas-backed generation:
Its `prepare_canvas_session` convenience method creates or reuses a project and a same-name folder. Use it only when that organization matches the task; it is not a prerequisite for MCP or REST calls. Convenience generation methods wait locally while the server uses short asynchronous submit/status requests. Use `submit_generation`, `get_generation`, and `wait_for_generation` for caller-controlled orchestration; see [requests and outputs](references/requests-and-outputs.md).
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
session = client.prepare_canvas_session("新画板")
client.generate_image(
"生成一张 16:9 幻想森林游戏背景",
canvasSession=session,
assetLabel="森林背景",
aspectRatio="16:9",
imageSize="1K",
artSpec={
"assetType": "background",
"subject": "幻想森林主视觉",
"style": "手绘游戏概念图",
"palette": "翡翠绿与金色光斑",
"composition": "横版,中心留出角色站位",
"format": "16:9, 1K",
"constraints": "无文字、无 UI 按钮",
"references": [],
},
)
```
For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
```python
session = client.prepare_canvas_session("去背景画布")
client.remove_background(
"editor-upload/object.png",
source_width=720,
source_height=1280,
canvasSession=session,
assetLabel="去背景结果",
)
```
Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
## Guardrails
- Do not change the fixed production base URL in generated examples.
- Do not move the API Key into environment variables, source files, generated projects, logs, docs, screenshots, or shell snippets containing literal secrets.
- Do not treat a Data URL, Blob URL, expiring signed URL, worker lease, or provider diagnostic as a durable result.
- Do not reconstruct authoritative canvas, resource, or library snapshots from a compact generation response.
- Do not replace icon-spritesheet generation with ordinary image generation when the deliverable requires a reusable transparent atlas.
Stay within `/api/external/v1` for this integration. Internal workers, queues, admin/profile APIs, and SpacetimeDB endpoints are outside this contract.
@@ -4,6 +4,44 @@ Use this reference after selecting a capability. Treat `GET /api/external/v1/ope
All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key.
## MCP Tool to API Map
The hosted MCP offers the following tools. Choose the task tool when its action matches the request; the operation tool calls the indicated REST operation directly. Task tools with actions take `{ "action": "...", "input": { ... } }`; tools without actions take the operation fields directly. `idempotencyKey` is top-level in task tools. Operation tools use `body`, `pathParameters`, and `queryParameters` wrappers from their live input schemas. Read the live tool schema and OpenAPI for exact required fields.
| REST operation | Task tool (action) | Operation tool |
| --- | --- | --- |
| `GET /api/external/v1/openapi.json` | — | `get_external_open_api_json` |
| `GET /api/external/v1/editor/projects` | `find_canvas_projects` (`list`) | `list_editor_projects` |
| `GET /api/external/v1/editor/projects/recent` | `find_canvas_projects` (`recent`) | `load_recent_editor_project` |
| `GET /api/external/v1/editor/projects/{projectId}` | `find_canvas_projects` (`get`), `find_assets` (`get_project_resources`), `edit_canvas` (`get`) | `get_editor_project` |
| `POST /api/external/v1/editor/projects` | `manage_canvas_projects` (`create`) | `create_editor_project` |
| `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `manage_canvas_projects` (`rename`) | `rename_editor_project` |
| `DELETE /api/external/v1/editor/projects/{projectId}` | `delete_resources` (`delete_project`) | `delete_editor_project` |
| `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `edit_canvas` (`save_layout`) | `save_editor_project_canvas` |
| `POST /api/external/v1/editor/projects/{projectId}/resources` | `edit_canvas` (`register_resource`) | `create_editor_project_resource` |
| `POST /api/external/v1/assets/direct-upload-tickets` | `prepare_asset_upload` (`create_upload_ticket`) | `create_external_direct_upload_ticket` |
| `POST /api/external/v1/assets/objects/confirm` | `prepare_asset_upload` (`confirm_upload`) | `confirm_external_asset_object` |
| `GET /api/external/v1/assets/read-url` | `find_assets` (`get_download_url`) | `get_external_asset_read_url` |
| `GET /api/external/v1/editor/assets/library` | `find_assets` (`list_library`) | `get_editor_asset_library` |
| `POST /api/external/v1/editor/assets/folders` | `organize_asset_library` (`create_folder`) | `create_editor_asset_folder` |
| `PATCH /api/external/v1/editor/assets/folders/{folderId}` | `organize_asset_library` (`update_folder`) | `update_editor_asset_folder` |
| `DELETE /api/external/v1/editor/assets/folders/{folderId}` | `delete_resources` (`delete_folder`) | `delete_editor_asset_folder` |
| `POST /api/external/v1/editor/assets` | `organize_asset_library` (`create_asset`) | `create_editor_asset` |
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
| `POST /api/external/v1/editor/ui-designs/assets/extractions` | `extract_ui_assets` | `extract_external_editor_ui_design_assets` |
| `POST /api/external/v1/editor/character-animations/generations` | `generate_character_animation` | `generate_external_editor_character_animation` |
| `POST /api/external/v1/editor/videos/generations` | `generate_video` | `generate_external_editor_video` |
| `POST /api/external/v1/editor/audios/sound-effects/generations` | `generate_audio` (`sound_effect`) | `generate_external_editor_sound_effect` |
| `POST /api/external/v1/editor/audios/background-music/generations` | `generate_audio` (`background_music`) | `generate_external_editor_background_music` |
| `GET /api/external/v1/generations/{operationId}` | `check_generation` | `get_external_editor_generation_job` |
The public `agent-integration.json`, `skill/SKILL.md`, and `skill.zip` routes and the MCP transport route are HTTP entry points, not callable MCP tools. The hosted resource URIs remain `genarrative://external-editor/skill`, `genarrative://external-editor/skill/references/capability-routing.md`, `genarrative://external-editor/skill/references/api-operations.md`, `genarrative://external-editor/skill/references/authentication-and-safety.md`, `genarrative://external-editor/skill/references/requests-and-outputs.md`, and `genarrative://external-editor/openapi`.
## Project and Canvas Operations
| Operation | Method and path | Minimum input |
@@ -23,7 +61,7 @@ Project listing supports two views:
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
- Hosted MCP `list_editor_projects` and `find_canvas_projects` (`list`) use `summary`; call `get_editor_project` or `find_canvas_projects` (`get`) after selecting a `projectId` when complete authoritative state is required.
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
## Asset and Upload Operations
@@ -42,6 +80,7 @@ Project listing supports two views:
| Delete asset record | `DELETE /api/external/v1/editor/assets/{assetId}` | `assetId` |
Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow.
`prepare_asset_upload` handles the ticket and confirmation as separate calls; it does not send local bytes to OSS or automatically register a project resource, asset record, or canvas layer. `manage_canvas_projects` (`create`) likewise does not create a same-name asset folder. Register or organize records only when the task needs them.
## Generation Operations
@@ -52,7 +91,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -69,10 +108,10 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
## Canvas and Library Field Rules
- Pass `projectId` and `canvasCompletion` to write generated output into the canvas.
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
- Pass `projectId` and `canvasCompletion` when the task calls for generated output in a canvas.
- Pass `assetFolderId` plus the relevant label field when the task calls for a library record. Neither destination requires the other, and their names need not match.
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
- Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume returned artifacts and persisted identities; never create a duplicate first-frame resource or asset.
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
- Reload project/library state after completion when full current state is required.
@@ -21,7 +21,7 @@ Authorization: Bearer <tnr_sk_...>
Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat.
Store it outside repositories in the user's private JSON file:
For hosted MCP, configure the Bearer token in the client's private connection settings. For the bundled REST helper, store it outside repositories in the user's private JSON file:
```text
~/.config/genarrative/external-editor-api.json
@@ -61,7 +61,7 @@ The OpenAPI document, integration manifest, raw Skill entry, and Skill archive a
For each logical generation:
1. Create one printable ASCII `Idempotency-Key` of 1-128 bytes.
1. Create one printable ASCII key of 1-128 bytes. MCP takes `idempotencyKey` at the top level of the tool arguments (outside `input`); REST takes the `Idempotency-Key` header.
2. Persist the key with the exact request body and returned `operationId`.
3. If submission transport fails or the response is lost, resend only the exact same body with the same key.
4. Never allocate a new key merely because the outcome is unknown.
@@ -69,15 +69,21 @@ For each logical generation:
Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation.
Keep the API operation as well as the request and key unchanged across a submission retry. A different tool name does not create a separate idempotency namespace. When an operation ID is known, query `check_generation` directly. A rejected submission is not permission to switch keys and generate again.
`manage_canvas_projects/create`, `edit_canvas/register_resource`, and `organize_asset_library/create_folder` accept optional top-level `idempotencyKey`. Other non-generation actions do not accept it; in particular, `create_asset` is not an idempotent generation submission.
## Local Reference Upload
Do not ask the user to convert local files to base64. Upload from the Agent/client machine:
1. Detect the original filename, MIME type, byte length, and image dimensions when relevant.
2. Create a ticket with `POST /api/external/v1/assets/direct-upload-tickets`.
2. Call `prepare_asset_upload` with `action=create_upload_ticket` and the ticket body in `input` (REST: `POST /api/external/v1/assets/direct-upload-tickets`).
3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`.
4. Confirm the object with `POST /api/external/v1/assets/objects/confirm`.
5. Pass the confirmed stable `objectKey` to the selected editor operation.
4. Call `prepare_asset_upload` with `action=confirm_upload` and the confirmation body in `input` (REST: `POST /api/external/v1/assets/objects/confirm`).
5. Pass the confirmed stable `objectKey` where the selected tool permits it. For operations requiring a registered source, register a project resource or asset first and use its ID.
The MCP tool does not transfer file bytes and does not accept a local path or base64. The client needs an HTTP/file-transfer capability for step 3. Object ownership comes from the API Key; do not supply `ownerUserId`. Confirmation alone creates neither a library record nor a canvas layer.
For a private reference image, use a ticket body shaped like:
@@ -107,7 +113,7 @@ Confirm with the actual file metadata:
`contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined.
For character animation, reuse a real canvas layer ID when available. For a local-only source, derive a stable synthetic `sourceLayerId`, such as `external-reference-hero`, from the filename and keep it unchanged across retries.
For character animation, use source identity and dimensions from the actual selected resource; do not invent an existing canvas layer. The bundled local-file helper can maintain its own stable source label, which is not evidence of a persisted canvas layer.
The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs:
@@ -126,7 +132,7 @@ Do not print the complete confirmation response if it may contain temporary acce
- Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input.
- Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output.
- Never use a Blob URL outside the browser process that created it.
- Use `GET /api/external/v1/assets/read-url` to obtain a short-lived `signedUrl` for display/download.
- Use `find_assets` with `action=get_download_url` (REST: `GET /api/external/v1/assets/read-url`) to obtain a short-lived `signedUrl` for display/download. This returns a URL; the client still performs any download.
- Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists.
## Logging and Command Safety
@@ -139,6 +145,8 @@ Do not print the complete confirmation response if it may contain temporary acce
## Scope and Retry Guardrails
- Generation spends account credits. Respect the user's authorized task and scope; do not restart generation merely because a requested derivative is missing.
- For `delete_resources`, identify the precise IDs and obtain authorization for the actual deletion scope. Project deletion cascades to its default canvas and project-resource metadata. Folder deletion moves its assets to the default folder; the default folder cannot be deleted. Deleting a folder or asset record does not delete the underlying OSS file.
- Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract.
- Do not call internal workers, queues, SpacetimeDB, or admin endpoints.
- Do not bypass upload confirmation or invent an object key.
@@ -10,21 +10,17 @@ Use this reference to translate user intent into a hosted MCP tool or its corres
- Public contract: `GET /api/external/v1/openapi.json`.
- Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`.
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. The MCP tool names are derived from OpenAPI `operationId` values in snake case; select by capability instead of memorizing the name.
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. Choose a task-oriented tool below for ordinary requests, or the corresponding operation tool in [API Operations](api-operations.md) when the request needs direct control of one REST call. All 44 tools remain available. Discover the live tool schema before calling it; OpenAPI remains the field-level authority. The public discovery and Skill download routes are listed below, but are not MCP tools.
## Canvas Session
## Project and Asset Destination
Before the first generation in a new conversation, obtain a canvas name unless the user already supplied an existing `projectId` and `assetFolderId`.
Use `find_canvas_projects` (`action=list`, `recent`, or `get`) to locate an existing canvas when the request involves one. Use `manage_canvas_projects` (`create` or `rename`) only when the user needs a project created or renamed. A new project does not create an asset folder automatically. Use `find_assets` and `organize_asset_library` when the task involves library records or folders. A project and folder may have different names, and either may be unnecessary for a standalone generation.
1. List or create a project. When creating one, use the canvas name as `title`.
2. Read the asset library. Reuse a folder with the same label or create one with the canvas name.
3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation returns the final formal resource and asset directly; use those records and never create a duplicate from the first frame.
For generation, pass `projectId` with `canvasCompletion` when the result should enter a canvas, and `assetFolderId` with the endpoint's label field when it should enter the library. Use both only when the task requires both destinations. Character animation returns its final resource and asset directly when those destinations are requested; do not duplicate its first frame.
## Art Spec Routing
Before art generation, normalize the user's request into:
For a series of related art requests, an optional reusable spec can carry the shared requirements:
```json
{
@@ -43,48 +39,48 @@ Infer what is already clear and ask only for missing fields that block the selec
## Intent Map
| User intent | MCP/REST capability |
| ----------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
| Redraw, retouch, or replace an existing image | Image edit |
| Remove the background from an existing image | Background removal |
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
| Extract marked assets from an existing UI design | UI design asset extraction |
| Animate a character into frames | Character animation generation |
| Generate video | Video generation |
| Generate a sound effect | Sound-effect generation |
| Generate background music/BGM | Background-music generation |
| Upload a local image/audio/video asset | Upload ticket -> OSS form upload -> object confirm |
| Save viewport/layers | Canvas save |
| Create, load, rename, or delete a canvas | Project operations |
| Organize folders and asset records | Asset-library operations |
| Obtain temporary access to private media | Signed read URL |
| Check generation progress or retrieve its result | Generation query |
| User intent | MCP tool and action |
| --- | --- |
| Find, open, create, or rename a canvas project | `find_canvas_projects` (`list`, `recent`, `get`); `manage_canvas_projects` (`create`, `rename`) |
| Read project resources or library records | `find_assets` (`get_project_resources`, `list_library`) |
| Create or change folders and asset records | `organize_asset_library` (`create_folder`, `update_folder`, `create_asset`, `update_asset`) |
| Upload a local image/audio/video asset | `prepare_asset_upload` (`create_upload_ticket`), client-side OSS form upload, then `prepare_asset_upload` (`confirm_upload`) |
| Register existing media in a project, read a canvas, or save its full layout | `edit_canvas` (`register_resource`, `get`, `save_layout`) |
| Generate a background, character, spec, UI mockup, or publication image | `generate_image` |
| Retouch an existing image, make a reference variation, or remove its background | `modify_image` (`edit`, `variation`, `remove_background`) |
| Build a transparent icon/game atlas from a registered visual spec | `generate_icon_spritesheet` |
| Generate marked assets from an existing UI design | `extract_ui_assets` |
| Animate a character into frames | `generate_character_animation` |
| Generate video | `generate_video` |
| Generate a sound effect or background music | `generate_audio` (`sound_effect`, `background_music`) |
| Check generation progress or retrieve its result | `check_generation` |
| Obtain temporary access to private media | `find_assets` (`get_download_url`) |
| Delete an exact project, folder, or asset record | `delete_resources` (`delete_project`, `delete_folder`, `delete_asset`) |
For tools with actions, send `{ "action": "...", "input": { ... } }`; place `idempotencyKey` at the top level when supported or required. Tools without actions accept operation fields directly, with `idempotencyKey` at the top level for generation. The direct operation tools use `body`, `pathParameters`, and `queryParameters` wrappers as shown by their live schemas.
Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation.
## Route-Specific Decisions
- Use image edit when the requested output replaces or modifies a source image. With `projectId`, pass `targetLayerId` to replace an existing layer when no explicit `canvasCompletion` is supplied.
- Use `modify_image` `edit` when the requested output modifies a registered source image. Use `variation` when reference images should guide a new `quick-edit` image; it is image generation with fixed `kind="quick-edit"`. Use `remove_background` for a static source image. With `projectId`, `targetLayerId` may replace a matching existing layer when no explicit `canvasCompletion` is supplied.
- Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects.
- Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation.
- Use a project layer ID as character animation `sourceLayerId` when one exists. For a local-only source, derive a stable synthetic ID from the filename.
- Character animation requires a real `sourceLayerId`, source image, and dimensions from an existing resource. A local-only file must first be uploaded and registered where needed; do not invent a layer ID.
- For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`.
- Use `signedUrl` only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation.
- `prepare_asset_upload` obtains a ticket and confirms an uploaded object; it does not transfer the file or register a project resource, asset record, or canvas layer. Use `edit_canvas` `register_resource` or `organize_asset_library` `create_asset` only when the task needs those records.
- Use temporary signed URLs only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation as each operation permits.
## AI Game Creator Canonical Visual DAG
## Example: Reusable Icon Assets
Keep the existing autonomous-build task graph. Do not add a parallel task system or collapse these artifacts into one ordinary generation request:
When the task needs a visual spec and a reusable icon atlas:
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
1. Reuse an existing registered `icon-spec`, or generate the requested spec using `generate_image` with `kind=spec` and register it as `assetKind=icon-spec` if necessary.
2. Call `generate_icon_spritesheet` with that registered ID as `referenceId`, concrete `iconDescriptions`, and explicit `sliceMode`. Choose `grid` only for requested equal cells or fixed slots and provide those `gridX`/`gridY` values; otherwise use `connected-components`, optionally with `sliceCount`.
3. Query `check_generation` and inspect the full sheet and actual slices. Preserve warnings; a usable full sheet does not imply that individual slices exist. Use returned slice identities and dimensions rather than guessing crop coordinates.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
An ordinary UI mockup or uploaded image is not automatically an `icon-spec`. For extracting marked components from a UI design, use `extract_ui_assets`, which includes generation and does not promise pixel-exact cropping.
## Scope Boundary
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`.
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`. The hosted MCP also exposes the Skill and OpenAPI resources at `genarrative://external-editor/skill`, its four `skill/references/*.md` URIs, and `genarrative://external-editor/openapi`; keep those URI names unchanged.
@@ -4,15 +4,46 @@ Use this reference to build generation payloads, carry canvas/library context, p
## Contents
- [MCP Argument Shapes](#mcp-argument-shapes)
- [Asynchronous Submission](#asynchronous-submission)
- [Polling State Machine](#polling-state-machine)
- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
- [Saving Existing Canvas Layout](#saving-existing-canvas-layout)
- [Art Spec and Image Request](#art-spec-and-image-request)
- [Local Reference Requests](#local-reference-requests)
- [Compact Completed Result](#compact-completed-result)
- [Warning Semantics](#warning-semantics)
- [Output Handling Checklist](#output-handling-checklist)
## MCP Argument Shapes
Pass these objects as the `arguments` of the named tool in `tools/call`. They are not REST request envelopes.
Single-function tools take business fields directly. For example, `generate_image`:
```json
{
"prompt": "一张横版幻想森林背景,无文字",
"aspectRatio": "16:9",
"imageSize": "1K",
"idempotencyKey": "forest-image-001"
}
```
Multi-function tools take `action` and `input`. A generation key stays outside `input`. For example, `generate_audio`:
```json
{
"action": "sound_effect",
"input": {"prompt": "轻柔的游戏菜单确认音", "duration": 1},
"idempotencyKey": "menu-sound-001"
}
```
Keys above identify distinct example requests; create and persist your own key for each new logical generation. An action with no business fields still requires `input: {}`, such as `find_canvas_projects` with `action=list`. Use only fields belonging to the selected action; do not combine branches. REST examples below use the business body directly and put the key in the HTTP header instead.
MCP returns business data in `structuredContent`, without the REST `data` envelope. Check `isError` before using it; an HTTP-successful MCP exchange can still carry a tool error. Generation acceptance contains an `operationId`, not the final media.
## Asynchronous Submission
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
@@ -50,7 +81,7 @@ Persist the key, exact request body, and `operationId`. If submission outcome is
## Polling State Machine
Poll `statusUrl`, or `GET /api/external/v1/generations/{operationId}`, no faster than `pollAfterMs`:
With MCP, call `check_generation` with `{"operationId":"<returned operationId>"}`. Each call queries once and does not wait for completion. With REST, poll `statusUrl` or `GET /api/external/v1/generations/{operationId}`. Query no faster than `pollAfterMs`:
- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again.
- `completed`: consume the compact `result` and all warning fields, then stop polling.
@@ -75,10 +106,10 @@ Background removal uses the same submission and polling state machine. `sourceIm
## Canvas and Asset-Library Completion
For endpoints that support these fields, include:
Choose destinations according to the task. Locate an existing project with `find_canvas_projects` and inspect folders with `find_assets/list_library`; create missing destinations with `manage_canvas_projects/create` and `organize_asset_library/create_folder` only when needed. Project creation does not create a folder. For endpoints that support the requested destinations, include:
- `projectId`: target canvas project.
- `assetFolderId`: folder whose label matches the canvas name.
- `assetFolderId`: target asset-library folder; its name need not match the project.
- `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label.
- `canvasCompletion`: backend canvas placement instructions.
@@ -102,15 +133,24 @@ A minimal `canvasCompletion` is:
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed.
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
## Saving Existing Canvas Layout
1. Call `edit_canvas` with `action=get` and `input.projectId` to read the latest project and canvas revision.
2. Build the intended complete `viewport` and `layers`, preserving unrelated layers. `save_layout` replaces the layout; it is not a one-layer patch.
3. Call `edit_canvas` with `action=save_layout` and `input` containing `projectId`, the read `expectedRevision`, and the complete `viewport` and `layers`.
4. On a revision conflict, reread and reconcile with the current layout before retrying. Do not blindly resend stale layers with a refreshed revision.
`edit_canvas/register_resource` registers existing media but does not create a canvas layer. `organize_asset_library/create_asset` creates metadata but does not upload or generate media. For generated media placement, prefer the generation tool's supported `canvasCompletion`; inspect returned identities before registering anything again.
## Art Spec and Image Request
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt:
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
```json
{
@@ -177,11 +217,11 @@ Image edit/redraw has a stricter main-source identity rule. After upload confirm
Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
For character animation, use the selected source identity and actual dimensions. The following is a business-body example; `generate_character_animation` also requires a top-level `idempotencyKey`:
```json
{
"sourceLayerId": "external-reference-hero",
"sourceLayerId": "<selected source layer ID>",
"sourceImageSrc": "<confirmed objectKey>",
"sourceWidth": 720,
"sourceHeight": 1280,
@@ -209,7 +249,7 @@ The completed `result` may contain stable artifact fields such as:
- `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata.
- `warning` and `sliceWarning` structures.
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `/assets/read-url` for temporary access to a stable `objectKey`.
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `find_assets/get_download_url` for temporary access to a stable `objectKey` (REST: `/assets/read-url`). Reading a record or obtaining a URL does not itself inspect or download the media.
## Warning Semantics
+1
View File
@@ -65,6 +65,7 @@ temp*build*/
/apps/preview-deployer-web/node_modules/
/server-rs/.spacetimedb/
/server-rs/.data/
**/server-rs/.data/
/public/generated-animations
/public/generated-character-drafts
/public/generated-characters
+16
View File
@@ -207,6 +207,22 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**逻辑回合**:
Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
_Avoid_: Codex 原生回合、原生日志、进程生命周期
**接单**:
把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
_Avoid_: 发送成功、命令调用、接口返回
**拒单**:
接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
_Avoid_: 回合失败、执行失败、失败事件
**在途回合**:
界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
_Avoid_: 运行中回合、乐观锁、发送队列
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
@@ -6,7 +6,7 @@
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
"model": "platform-default",
"apiKind": "openai_responses",
"reasoningEffort": "max",
"stream": true,
@@ -1494,6 +1494,15 @@ if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
// 首次启动模板必须写入官方路由占位模型(与 config.rs 的
// OFFICIAL_LLM_ROUTER_DEFAULT_MODEL 同源):钉死具体上游模型名会随上游目录
// 变动失效,留空则首启配置不合法。
if (defaultAppConfig.llm?.model !== 'platform-default') {
throw new Error(
'AI game creator shell default llm.model must stay the official route placeholder',
);
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
@@ -0,0 +1,269 @@
//! ACL 提权修复目标的并发去重与结果记忆。
//!
//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果;
//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长,
//! 避免自动重试把用户反复拽回安全桌面。
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Condvar, LazyLock, Mutex};
use std::time::{Duration, Instant};
/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AclRepairOutcome {
Repaired,
Denied(String),
Failed(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AclRepairGateResult {
Executed(AclRepairOutcome),
Reused(AclRepairOutcome),
/// leader 在等待窗口内仍未结束(例如 UAC 无人应答);调用方按失败关闭处理。
WaitTimedOut,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct AclRepairPolicy {
pub(crate) success_cooldown: Duration,
pub(crate) denial_cooldown: Duration,
pub(crate) failure_cooldown: Duration,
pub(crate) wait_timeout: Duration,
/// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。
/// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它,
/// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。
pub(crate) leader_deadline: Duration,
}
impl AclRepairPolicy {
fn cooldown_for(&self, outcome: &AclRepairOutcome) -> Duration {
match outcome {
AclRepairOutcome::Repaired => self.success_cooldown,
AclRepairOutcome::Denied(_) => self.denial_cooldown,
AclRepairOutcome::Failed(_) => self.failure_cooldown,
}
}
fn retention(&self) -> Duration {
self.success_cooldown
.max(self.denial_cooldown)
.max(self.failure_cooldown)
}
}
struct Entry {
running: bool,
outcome: Option<AclRepairOutcome>,
recorded_at: Option<Instant>,
/// leader 起跑时刻,用于判定该 leader 是否已经卡死。
started_at: Instant,
/// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。
leader_id: u64,
}
pub(crate) struct AclRepairGate<K> {
entries: Mutex<HashMap<K, Entry>>,
settled: Condvar,
next_leader_id: AtomicU64,
}
impl<K: Clone + Eq + Hash> AclRepairGate<K> {
pub(crate) fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
settled: Condvar::new(),
next_leader_id: AtomicU64::new(1),
}
}
/// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行,
/// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。
pub(crate) fn run<F>(
&self,
key: K,
now: Instant,
policy: &AclRepairPolicy,
execute: F,
) -> AclRepairGateResult
where
F: FnOnce() -> AclRepairOutcome,
{
let wait_deadline = Instant::now() + policy.wait_timeout;
let mut entries = lock(&self.entries);
loop {
match entries.get(&key) {
Some(entry) if entry.running => {
// 卡死的 leader(例如 `Start-Process -Wait` 真挂住)不能永久占住这个 key:
// 超过 leader_deadline 就由新调用接管,否则该目标在进程重启前只会一直失败关闭。
if now.saturating_duration_since(entry.started_at) >= policy.leader_deadline {
break;
}
let remaining = wait_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return AclRepairGateResult::WaitTimedOut;
}
let (guard, _) = self
.settled
.wait_timeout(entries, remaining)
.unwrap_or_else(|poisoned| poisoned.into_inner());
entries = guard;
}
Some(entry) => {
let reusable = entry.outcome.clone().zip(entry.recorded_at).filter(
|(outcome, recorded_at)| {
now.saturating_duration_since(*recorded_at)
< policy.cooldown_for(outcome)
},
);
match reusable {
Some((outcome, _)) => return AclRepairGateResult::Reused(outcome),
None => break,
}
}
None => break,
}
}
prune(&mut entries, now, policy);
let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed);
entries.insert(
key.clone(),
Entry {
running: true,
outcome: None,
// 结果尚未落库:冷却基准只在真正记录结果时才写。
recorded_at: None,
started_at: now,
leader_id,
},
);
drop(entries);
let guard = LeaderGuard {
gate: self,
key: key.clone(),
leader_id,
armed: true,
};
let outcome = execute();
guard.complete(outcome)
}
/// 用户主动操作后允许重新尝试提权:清掉「被拒绝」的记忆。
pub(crate) fn clear_denials(&self) {
let mut entries = lock(&self.entries);
entries.retain(|_, entry| {
entry.running || !matches!(entry.outcome, Some(AclRepairOutcome::Denied(_)))
});
drop(entries);
self.settled.notify_all();
}
#[cfg(test)]
pub(crate) fn is_running(&self, key: &K) -> bool {
lock(&self.entries)
.get(key)
.is_some_and(|entry| entry.running)
}
}
impl<K> Default for AclRepairGate<K>
where
K: Clone + Eq + Hash,
{
fn default() -> Self {
Self::new()
}
}
struct LeaderGuard<'a, K: Clone + Eq + Hash> {
gate: &'a AclRepairGate<K>,
key: K,
leader_id: u64,
armed: bool,
}
impl<K: Clone + Eq + Hash> LeaderGuard<'_, K> {
fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult {
self.armed = false;
let mut entries = lock(&self.gate.entries);
// 只在仍是当前 leader 时落库:leader 卡死被接管后,迟到的结果必须丢弃,
// 否则会把接管者已经写下的结果覆盖回去。
if let Some(entry) = entries.get_mut(&self.key) {
if entry.leader_id == self.leader_id {
entry.running = false;
entry.outcome = Some(outcome.clone());
// 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着
// 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,
// 紧接着的自动重查会立刻再弹一次。
entry.recorded_at = Some(Instant::now());
}
}
drop(entries);
self.gate.settled.notify_all();
AclRepairGateResult::Executed(outcome)
}
}
impl<K: Clone + Eq + Hash> Drop for LeaderGuard<'_, K> {
/// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。
fn drop(&mut self) {
if !self.armed {
return;
}
let mut entries = lock(&self.gate.entries);
if let Some(entry) = entries.get_mut(&self.key) {
if entry.leader_id == self.leader_id {
entry.running = false;
entry.outcome = Some(AclRepairOutcome::Failed(
"AGC ACL 提权修复执行线程异常退出".to_string(),
));
entry.recorded_at = Some(Instant::now());
}
}
drop(entries);
self.gate.settled.notify_all();
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn prune<K: Eq + Hash>(entries: &mut HashMap<K, Entry>, now: Instant, policy: &AclRepairPolicy) {
// 只是防止 map 随进程生命周期无限增长;窗口远大于冷却期即可。
let retention = policy.retention().saturating_mul(4);
entries.retain(|_, entry| {
if entry.running {
return true;
}
entry
.recorded_at
.is_none_or(|recorded_at| now.saturating_duration_since(recorded_at) < retention)
});
}
/// 提权修复的进程级闸门;key = (规范化目标路径, scope 名)。
pub(crate) type AclRepairKey = (String, &'static str);
pub(crate) static ACL_REPAIR_GATE: LazyLock<AclRepairGate<AclRepairKey>> =
LazyLock::new(AclRepairGate::new);
pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy {
success_cooldown: Duration::from_secs(30),
denial_cooldown: Duration::from_secs(120),
failure_cooldown: Duration::from_secs(15),
wait_timeout: Duration::from_secs(60),
// 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。
leader_deadline: Duration::from_secs(300),
};
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。
pub(crate) fn clear_acl_repair_denials() {
ACL_REPAIR_GATE.clear_denials();
}
@@ -33,6 +33,9 @@ mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_accept;
mod direct_turn_error;
mod direct_turn_failure;
mod direct_turn_stream;
mod direct_validation;
mod generation;
@@ -66,6 +69,9 @@ pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_accept::*;
pub(crate) use direct_turn_error::*;
pub(crate) use direct_turn_failure::*;
pub(crate) use direct_turn_stream::*;
pub(crate) use direct_validation::DirectValidationConfig;
pub(crate) use generation::*;
@@ -1,7 +1,7 @@
//! Native / third-party approval adapter. The host execution session owns policy
//! and persistence; this module only binds the app-server protocol to its leases.
use super::super::{direct_delivery, direct_execution, direct_validation};
use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError};
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
use serde_json::{json, Value};
@@ -16,11 +16,25 @@ use tokio::sync::{watch, Notify};
const MAX_PROTOCOL_ITEMS: usize = 2048;
const MAX_REQUEST_CACHE: usize = 512;
/// 逐次审批协议的版本门禁:发行构建只接受捆绑侧车的固定版本;开发构建用宿主自带的 Codex
/// (Linux 与未 stage 侧车时没有固定版本可用),按 profile 直接跳过该门禁。
pub(super) fn validate_approval_version(version: &str) -> Result<(), String> {
if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION {
return Ok(());
#[cfg(not(debug_assertions))]
{
if version.trim() == super::super::codex_cli::codex_bundle::CLI_VERSION {
return Ok(());
}
return Err(format!(
"direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本(期望 {},实际 {});禁止降级为无控制执行",
super::super::codex_cli::codex_bundle::CLI_VERSION,
version.trim()
));
}
#[cfg(debug_assertions)]
{
let _ = version;
Ok(())
}
Err("direct-execution-protocol: 当前 Codex 版本未通过逐次审批协议验收,请使用客户端配套版本;禁止降级为无控制执行".into())
}
pub(super) fn denied_response(id: u64, method: &str) -> Value {
@@ -78,12 +92,41 @@ pub(super) enum HostOutcome {
RepairRequired,
}
pub(super) fn outcome_text(outcome: HostOutcome) -> Result<String, platform_llm::LlmError> {
/// [`HostOutcome`] 的文本投影。
///
/// 返修要求([`HostOutcome::RepairRequired`])用**自己的变体**表达:它是控制流("继续当前返修
/// 批次"),不是失败。以前它伪装成 `LlmError::InvalidRequest("validation-source-changed: …")`,
/// 于是和真失败走同一条投影——终态被判成 `failed`、界面收到一条用户可见的失败说明。
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum HostOutcomeText {
/// 正常收尾:可展示的回复 / 交付报告文本。
Report(String),
/// 封口复核要求继续当前返修批次(控制流,不是失败)。
RepairRequired { detail: String },
}
/// 返修要求写回提示词时用的说明。
pub(super) const HOST_OUTCOME_REPAIR_REQUIRED_DETAIL: &str =
"宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次";
impl HostOutcomeText {
/// 投影成这一轮的收尾结果:正常报告是文本,返修要求是控制流(走 `Err` 侧自己的变体)。
pub(super) fn into_run_result(self) -> Result<String, super::DirectTurnRunFailure> {
match self {
Self::Report(text) => Ok(text),
Self::RepairRequired { detail } => {
Err(super::DirectTurnRunFailure::RepairRequired { detail })
}
}
}
}
pub(super) fn outcome_text(outcome: HostOutcome) -> HostOutcomeText {
match outcome {
HostOutcome::Report(report) => Ok(report),
HostOutcome::RepairRequired => Err(platform_llm::LlmError::InvalidRequest(
"validation-source-changed: 宿主收尾复核发现输入或证据变化,请读取交付状态后继续当前返修批次".into(),
)),
HostOutcome::Report(report) => HostOutcomeText::Report(report),
HostOutcome::RepairRequired => HostOutcomeText::RepairRequired {
detail: HOST_OUTCOME_REPAIR_REQUIRED_DETAIL.to_string(),
},
}
}
@@ -147,6 +190,11 @@ pub(super) struct ExecutionAdapter {
changed: Notify,
shutdown_gate: tokio::sync::Mutex<()>,
outcome: watch::Sender<Option<HostOutcome>>,
/// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败,
/// 原因与交付报告同一份文本。
turn_failure: Mutex<Option<DirectTurnError>>,
/// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。
host_stop_requested: AtomicBool,
}
fn identity(value: Option<&Value>) -> Option<&str> {
@@ -263,6 +311,8 @@ impl ExecutionAdapter {
changed: Notify::new(),
shutdown_gate: tokio::sync::Mutex::new(()),
outcome,
turn_failure: Mutex::new(None),
host_stop_requested: AtomicBool::new(false),
})
}
@@ -654,6 +704,7 @@ impl ExecutionAdapter {
}
pub(super) fn cancel_from_host(self: &Arc<Self>) {
self.request_host_stop();
if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) {
return;
}
@@ -672,6 +723,55 @@ impl ExecutionAdapter {
let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await;
}
/// 宿主判定"这一轮以失败收口":记下 `(分类, 原因)`,再把同一条原因写进宿主交付报告。
///
/// 谁调用:宿主亲眼看到或亲手判定的异常收场——执行通道断开(app-server 进程退出 / 流断 / 回合
/// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实,
/// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。
///
/// **宿主自己收束的这一轮不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,
/// 回合事件通道上看到的是同一个 `TransportClosed`;判据有两条,都收在这里,调用点不必各写一遍:
///
/// - [`Self::is_closed`]:适配器先于连接置位,说明这一轮是宿主在收束;
/// - [`Self::host_stop_requested`]:用户按过「终止」。`cancel_from_host` 先**同步**置位再异步
/// 中断会话,`closed` 与阶段都要等那个任务跑到才变,所以"标志已置、阶段未变"的窗口里到达的
/// 通道断开 / 中断都是宿主自己收尾的结果,不能记成 `transport-failed`。
///
/// 不记失败事实不等于不收束:原因照样写进报告(`interrupt` 会把它追加进去),便于核对。
///
/// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个
/// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态
/// 判定发生在收束之后;记不下原因,界面就只能看到"本轮已结束"、看不到为什么。
///
/// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由
/// 不得覆盖它。
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
let reason = failure.to_string();
if !self.is_closed() && !self.host_stop_requested() {
if let Ok(mut slot) = self.turn_failure.lock() {
if slot.is_none() {
*slot = Some(failure);
}
}
}
self.interrupt(&reason).await;
}
/// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。
pub(super) fn turn_failure(&self) -> Option<DirectTurnError> {
self.turn_failure.lock().ok().and_then(|slot| slot.clone())
}
/// 记下"用户主动要求终止这一轮"。用来把用户主动终止与 app-server 自己中断分开:
/// 前者不是失败,后者是(判据不能被事件到达的先后顺序左右,所以用标志而不是看阶段)。
pub(super) fn request_host_stop(&self) {
self.host_stop_requested.store(true, Ordering::Release);
}
pub(super) fn host_stop_requested(&self) -> bool {
self.host_stop_requested.load(Ordering::Acquire)
}
pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) {
let adapter = Arc::clone(self);
tokio::spawn(async move {
@@ -744,7 +844,19 @@ impl ExecutionAdapter {
.unwrap_or(true)
}
/// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。
///
/// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到
/// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。
/// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。
fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
// 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由
// `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的
// `Interrupted` 抹成一次没有原因的"已结束"。
match self.session.snapshot().map(|state| state.phase) {
Ok(ExecutionPhase::Completed) => "completed",
Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted",
@@ -1037,6 +1149,8 @@ pub(super) async fn wait_outcome(
#[cfg(test)]
mod tests {
use super::super::DirectTurnDeadline;
use super::*;
fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) {
@@ -1084,6 +1198,82 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn host_observed_failure_is_recorded_with_its_kind_and_reason() {
let (_temp, adapter) = fixture();
assert!(adapter.turn_failure().is_none());
assert!(!adapter.host_stop_requested());
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
})
.await;
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
let failure = adapter.turn_failure().expect("host fact must be recorded");
assert_eq!(
failure.wire_kind(),
Some(super::super::DirectTurnFailureKind::TransportFailed)
);
assert!(failure.to_string().contains("SIGKILL"));
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
assert!(adapter.report().contains("SIGKILL"));
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
adapter
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
})
.await;
let failure = adapter.turn_failure().expect("first reason is kept");
assert_eq!(
failure.wire_kind(),
Some(super::super::DirectTurnFailureKind::TransportFailed)
);
assert!(failure.to_string().contains("SIGKILL"));
assert!(!failure.to_string().contains("超时"));
}
/// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道
/// 上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。
#[tokio::test]
async fn host_ended_turn_is_not_a_failure() {
let (_temp, adapter) = fixture();
adapter.request_host_stop();
adapter.closed.store(true, Ordering::Release);
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "模型本次执行结束,回收原生后台子树".into(),
})
.await;
assert!(adapter.turn_failure().is_none());
assert!(adapter.host_stop_requested());
// 原因照样进报告:不算失败不等于不用记。
assert!(adapter.report().contains("模型本次执行结束"));
}
/// 用户按下的「终止」不记失败事实:`cancel_from_host` 先同步置位 `host_stop_requested`、再异步
/// 中断会话,这中间到达的通道断开 / 中断都是宿主自己收尾的结果,不能讲成 `transport-failed`。
#[tokio::test]
async fn user_requested_stop_is_not_recorded_as_a_failure() {
let (_temp, adapter) = fixture();
adapter.request_host_stop();
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
})
.await;
assert!(adapter.turn_failure().is_none());
assert!(adapter.host_stop_requested());
// 不算失败不等于不用记:原因照样进报告,排障能看到现场。
assert!(adapter.report().contains("SIGKILL"));
}
#[tokio::test]
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
let (_temp, adapter) = fixture();
@@ -1428,9 +1618,13 @@ mod tests {
super::super::super::codex_cli::codex_bundle::CLI_VERSION
)
.is_ok());
// 开发构建(含本测试构建)跳过版本门禁,只有发行构建要求严格等于固定版本。
#[cfg(debug_assertions)]
assert!(validate_approval_version("codex-cli 0.156.0").is_ok());
#[cfg(not(debug_assertions))]
for version in [
"codex-cli 0.155.0",
"codex-cli 0.154.0",
"codex-cli 0.156.0",
"unknown",
"0.155.1",
] {
File diff suppressed because it is too large Load Diff
@@ -697,10 +697,14 @@ pub(super) async fn finish_sealing(
}).await.map_err(|_| "delivery-finalize-worker-exited")?
}
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
///
/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。
pub(super) async fn review_reply(
root: &Path,
session: &Arc<ExecutionSession>,
) -> Result<Option<String>, String> {
) -> Result<Option<String>, DirectTurnError> {
if let Some(report) = terminal_report(session) {
return Ok(Some(report));
}
@@ -751,7 +755,9 @@ pub(super) async fn review_reply(
.map_err(|_| "delivery-review-worker-exited")??;
return Ok(Some(report));
}
Err(format!("delivery-review-required: {detail}"))
Err(DirectTurnError::ReviewRequired {
detail: format!("delivery-review-required: {detail}"),
})
}
#[cfg(test)]
@@ -914,10 +920,10 @@ mod tests {
assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0);
let (new_game, _new_host, required) = project_session(true);
for _ in 0..2 {
assert!(review_reply(new_game.path(), &required)
.await
.unwrap_err()
.starts_with("delivery-review-required:"));
assert!(matches!(
review_reply(new_game.path(), &required).await.unwrap_err(),
DirectTurnError::ReviewRequired { .. }
));
}
assert!(review_reply(new_game.path(), &required)
.await
@@ -704,9 +704,19 @@ pub(super) fn open_with_analytics_at(
impl ExecutionSession {
pub(super) fn bind_codex_executor(&self, path: &Path, version: &str) -> Result<(), String> {
if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION {
return Err("direct-execution-executor: 尚未验证该执行器的补丁协议".into());
// 发行构建只接受捆绑侧车固定版本;开发构建用宿主自带的 Codex,按 profile 跳过该门禁。
#[cfg(not(debug_assertions))]
{
if version.trim() != super::codex_cli::codex_bundle::CLI_VERSION {
return Err(format!(
"direct-execution-executor: 尚未验证该执行器的补丁协议(期望 {},实际 {})",
super::codex_cli::codex_bundle::CLI_VERSION,
version.trim()
));
}
}
#[cfg(debug_assertions)]
let _ = version;
let path = path
.canonicalize()
.map_err(|_| "direct-execution-executor: 无法锚定执行器")?;
@@ -651,6 +651,8 @@ fn patch_executor_identity_is_frozen_and_content_changes_are_rejected() {
std::fs::write(&path, "trusted test bytes").unwrap();
let pinned = super::super::codex_cli::codex_bundle::CLI_VERSION;
assert!(session.codex_executor().is_err());
// 开发构建跳过执行器版本门禁;发行构建仍然拒绝版本漂移。
#[cfg(not(debug_assertions))]
assert!(session
.bind_codex_executor(&path, "codex-cli 0.155.0")
.is_err());
@@ -437,8 +437,14 @@ mod tests {
async fn a_replaced_active_turn_marks_the_batch_stale() {
let (_temp, root) = project();
std::fs::write(root.join("code.js"), "unchanged").unwrap();
// 身份来自逻辑回合(Thread Manager):接单才是"这一轮在跑"的唯一登记。
let owner = Arc::new(std::sync::Mutex::new(Some(
DirectTaonierActiveInvocationGuard::enter(&root, "turn-before").unwrap(),
DirectTurnReservation::accept(
&direct_thread_id_for_project(&root),
"turn-before",
None,
)
.unwrap(),
)));
let swap = Arc::clone(&owner);
let result = read_batch_with(
@@ -449,7 +455,14 @@ mod tests {
let result = read_file(r, f, b);
let mut guard = swap.lock().unwrap();
drop(guard.take());
*guard = Some(DirectTaonierActiveInvocationGuard::enter(r, "turn-after").unwrap());
*guard = Some(
DirectTurnReservation::accept(
&direct_thread_id_for_project(r),
"turn-after",
None,
)
.unwrap(),
);
result
},
)
@@ -583,7 +596,14 @@ mod tests {
#[tokio::test]
async fn host_prefetch_keeps_data_out_of_system_rules_and_matches_active_turn() {
let (_temp, root) = project();
// 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。
let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap();
let _turn = DirectTurnReservation::accept(
&direct_thread_id_for_project(&root),
"prefetch-turn",
None,
)
.unwrap();
let data = prefetch_turn_input(&root, "prefetch-turn")
.await
.unwrap()
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,9 @@ use super::*;
pub(crate) fn normalize_direct_client_turn_id(
client_turn_id: Option<&str>,
) -> Result<String, String> {
) -> Result<String, DirectTurnError> {
let Some(client_turn_id) = client_turn_id else {
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string());
return Err(DirectTurnError::ClientTurnIdMissing);
};
let client_turn_id = client_turn_id.trim();
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
@@ -20,13 +20,28 @@ pub(crate) fn normalize_direct_client_turn_id(
.is_some_and(|byte| byte.is_ascii_alphanumeric());
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
if !valid_length || !valid_first || !valid_rest {
return Err(format!(
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字"
));
return Err(DirectTurnError::ClientTurnIdMalformed {
min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS,
max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS,
});
}
Ok(client_turn_id.to_string())
}
/// DirectProject 聊天命令:**只接单**,不再 await 整轮。
///
/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的
/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与
/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。
///
/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级
/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。
///
/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本
/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。
/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。
///
/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -34,42 +49,369 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> Result<String, String> {
let capture = crate::analytics::gui::capture_writer_context();
) -> Result<(), DirectTurnRejection> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
validate_direct_codex_user_item(root, &user_item)?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
if user_prompt.trim().is_empty() {
return Err("聊天内容不能为空".to_string());
}
let canonical_user_item =
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
.await
{
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
};
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
let boundary_turn_id = client_turn_id.clone();
chat_with_game_creator_direct_codex_typed(
root,
user_item,
creation_type,
client_turn_id,
analytics_attempt_id,
)
.await
.map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure))
}
/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**:
/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备
/// → 接单 → 落盘用户条目 → 后台起整轮。
///
/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的
/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始
/// 事件会排在用户消息之后。
async fn chat_with_game_creator_direct_codex_typed(
root: &Path,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> Result<(), DirectTurnError> {
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
// 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它只挡并发,**不是**
// 首页"运行中的项目"的来源(那张表由 Thread Manager 的逻辑回合导出),但仍必须与整轮同生
// 共死——随任务一起搬进后台。
let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
DirectTurnError::HostStateUnavailable {
detail: redact_agent_runtime_error(
root,
&format!("恢复上一轮陶泥儿整包事务失败:{error}"),
500,
),
}
})?;
validate_direct_codex_user_item(root, &user_item)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?;
let canonical_user_item =
serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected {
detail: error.to_string(),
})?;
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
.await
.map_err(|error| {
let detail = redact_agent_runtime_error(root, &error, 1800);
DirectTurnError::EnvironmentNotReady { detail }
})?;
// 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填**
// ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。
let thread_id = direct_thread_id_for_project(root);
let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id);
let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?;
// 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。
if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) {
// 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是
// 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败
// 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。
// 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口
// 用户消息的助手回复,而且失败会被静默掉。
let failure = DirectTurnError::EnvironmentNotReady {
detail: redact_agent_runtime_error(
root,
&format!("写入本项目对话历史失败:{error}"),
600,
),
};
reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure));
return Ok(());
}
// 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上
// app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到
// `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。
crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item);
let capture = crate::analytics::gui::capture_writer_context();
let root = root.to_path_buf();
tauri::async_runtime::spawn(async move {
run_accepted_direct_turn(
root,
turn_id,
user_prompt,
creation_type,
canonical_user_item,
capture,
analytics_attempt_id,
active_invocation,
reservation,
)
.await;
});
Ok(())
}
/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。
///
/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的
/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。
///
/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里:
/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation`
/// 是逻辑回合的占用。
#[allow(clippy::too_many_arguments)]
async fn run_accepted_direct_turn(
root: std::path::PathBuf,
turn_id: String,
user_prompt: String,
creation_type: Option<String>,
canonical_user_item: serde_json::Value,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<String>,
_active_invocation: DirectTaonierActiveInvocationGuard,
reservation: DirectTurnReservation,
) {
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id);
let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
&root,
&user_prompt,
creation_type.as_deref(),
Some(&turn_emitter),
canonical_user_item,
Some(&emitter),
Some(canonical_user_item),
capture,
analytics_attempt_id.as_deref(),
)
.await
{
Ok(reply) => reply,
Err(error) => return Err(error),
};
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
Ok(reply)
.await;
match outcome {
Ok(reply) => {
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
emitter.emit("completed", Some("none"), Some(reply), None);
}
Err(error) => {
// 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。
// 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。
reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent};
/// 接单之后的早退也必须有终态。
///
/// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的
/// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须
/// 补出 `turn.completed`——这正是接单化要买的那条不变式。
#[tokio::test]
async fn a_failure_after_accept_still_closes_the_logical_turn() {
let temp = tempfile::tempdir().expect("temp dir");
let root = temp.path().join("not-a-project");
std::fs::create_dir_all(&root).expect("create project dir");
let thread_id = direct_thread_id_for_project(&root);
let subscription = subscribe_direct_thread(&thread_id);
let _ = consume_direct_thread(&subscription.subscription_id);
let reservation =
DirectTurnReservation::accept(&thread_id, "turn-1", Some("direct-codex:turn-1:user"))
.expect("accept logical turn");
let invocation =
DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation");
run_accepted_direct_turn(
root.clone(),
"turn-1".to_string(),
"你好".to_string(),
None,
serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-1:user",
"content": [{ "type": "input_text", "text": "你好" }],
}),
None,
None,
invocation,
reservation,
)
.await;
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume logical turn")
.events;
let terminal = events
.iter()
.filter_map(|event| match event {
DirectThreadEvent::TurnCompleted {
status,
failure,
user_item_id,
..
} => Some((status, failure, user_item_id)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}");
let (status, failure, user_item_id) = terminal[0];
assert_eq!(status, "failed");
let failure = failure.as_ref().expect("失败终态必须带载荷");
assert!(
!failure.message.trim().is_empty(),
"接单之后的失败必须带上原因"
);
assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
}
/// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。
///
/// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败
/// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置
/// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户
/// 气泡再自成一个 0.0 秒的假回合。
///
/// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目"
/// 一定在最前面,之后才可能有失败终态。
#[tokio::test]
async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() {
let temp = tempfile::tempdir().expect("temp dir");
let root = temp.path().join("direct-user-item-first");
crate::init_local_game_project_at(&root, "direct-user-item-first", "用户条目先下发")
.expect("init project");
let thread_id = direct_thread_id_for_project(&root);
let subscription = subscribe_direct_thread(&thread_id);
let _ = consume_direct_thread(&subscription.subscription_id);
let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-1:user",
"content": [{ "type": "input_text", "text": "hello" }],
}))
.expect("canonical user item");
chat_with_game_creator_direct_codex_typed(
&root,
user_item,
None,
Some("turn-1".to_string()),
None,
)
.await
.expect("接单成立:命令只回报接单");
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume logical turn")
.events;
assert!(
matches!(
events.first(),
Some(DirectThreadEvent::TurnStarted { user_item_id, .. })
if user_item_id.as_deref() == Some("direct-codex:turn-1:user")
),
"第一条必须是带身份的回合开始:{events:?}"
);
assert!(
matches!(
events.get(1),
Some(DirectThreadEvent::ItemCompleted { item, .. })
if item.item_id() == "direct-codex:turn-1:user"
),
"第二条必须是本轮的开口用户条目:{events:?}"
);
let terminal = events
.iter()
.position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. }));
assert!(
terminal.is_none_or(|index| index > 1),
"终态只能在用户条目之后:{events:?}"
);
// 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。
let persisted = std::fs::read_to_string(root.join(".agent/conversations/project.jsonl"))
.expect("read project history");
assert!(
persisted.contains("direct-codex:turn-1:user"),
"用户条目必须已经落盘:{persisted}"
);
}
/// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。
///
/// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条
/// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写
/// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条
/// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。
#[tokio::test]
async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() {
let temp = tempfile::tempdir().expect("temp dir");
let root = temp.path().join("direct-history-write-failure");
crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败")
.expect("init project");
let thread_id = direct_thread_id_for_project(&root);
let subscription = subscribe_direct_thread(&thread_id);
let _ = consume_direct_thread(&subscription.subscription_id);
// 接下来这次追加写的两次尝试都按"争用失败"返回:确定性地走到落盘失败分支。
std::fs::write(
root.join(".agent/runtime/test-fail-next-direct-project-history-append"),
"9",
)
.expect("write history contention injection");
let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-1:user",
"content": [{ "type": "input_text", "text": "生成一个游戏" }],
}))
.expect("canonical user item");
chat_with_game_creator_direct_codex_typed(
&root,
user_item,
None,
Some("turn-1".to_string()),
None,
)
.await
.expect("接单之后的失败不再回到命令返回值:命令只回报接单成立");
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume logical turn")
.events;
let terminals = events
.iter()
.filter_map(|event| match event {
DirectThreadEvent::TurnCompleted {
status, failure, ..
} => Some((status, failure)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}");
let (status, failure) = terminals[0];
assert_eq!(status, "failed");
let failure = failure.as_ref().expect("失败终态必须带载荷");
assert!(
failure.message.contains("写入本项目对话历史失败"),
"{}",
failure.message
);
// 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案
// 不许出现在这里(它只属于可留痕的拒单)。
assert!(
!failure.message.contains("direct-codex-failure"),
"{}",
failure.message
);
// 占用已释放:下一轮还能接单。
assert!(!crate::agent::direct_thread_turn_is_active(&thread_id));
assert!(DirectTurnReservation::accept(
&thread_id,
"turn-2",
Some("direct-codex:turn-2:user")
)
.is_ok());
}
}
@@ -35,6 +35,46 @@ struct SubscriberState {
cursor: u64,
}
/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。
///
/// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_direct_active_turns`]):这一格的
/// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_direct_thread_active_turn`]
/// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。
///
/// 两个身份别混:
/// - `token` 是这一次接单的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态,
/// 避免迟到的旧占用把新回合的边界顶掉。它不对外。
/// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。
#[derive(Clone, Debug)]
struct ActiveDirectTurn {
token: String,
turn_id: String,
project_name: Option<String>,
started_at: u64,
status: String,
activity: Option<String>,
updated_at: u64,
sequence: u64,
}
/// 首页「运行中的项目」的一条快照。
///
/// `project_path` 与线上其它地方的项目身份取同一个字符串:Thread Manager 的线程身份就是项目的
/// canonical 路径(见 `direct_thread_id_for_project`),所以快照里的项目身份与事件流里的身份
/// 永远能对上,不需要调用方再做一次归一。
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectActiveTurnSnapshot {
pub(crate) project_path: String,
pub(crate) project_name: Option<String>,
pub(crate) turn_id: String,
pub(crate) started_at: u64,
pub(crate) status: String,
pub(crate) activity: Option<String>,
pub(crate) updated_at: u64,
pub(crate) sequence: u64,
}
#[derive(Clone, Debug)]
struct ThreadState {
next_seq: u64,
@@ -43,6 +83,8 @@ struct ThreadState {
total_bytes: usize,
active_items: HashSet<String>,
unresolved_requests: HashSet<String>,
/// 未收口的逻辑回合。`None` 表示这个 thread 没有正在跑的回合。
active_turn: Option<ActiveDirectTurn>,
/// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。
///
/// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件,
@@ -63,6 +105,7 @@ impl Default for ThreadState {
total_bytes: 0,
active_items: HashSet::new(),
unresolved_requests: HashSet::new(),
active_turn: None,
lifecycle_anchor: None,
subscribers: HashMap::new(),
}
@@ -155,6 +198,139 @@ impl DirectThreadManager {
}
}
/// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。
///
/// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列,
/// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用
/// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断
/// "撞的是同一轮还是另一轮"。
fn accept_turn(
&mut self,
thread_id: &str,
token: &str,
turn_id: &str,
user_item_id: Option<&str>,
started_at_ms: u64,
) -> Result<DirectThreadEvent, String> {
{
let thread = self.threads.entry(thread_id.to_string()).or_default();
if let Some(active) = thread.active_turn.as_ref() {
return Err(active.turn_id.clone());
}
thread.active_turn = Some(ActiveDirectTurn {
token: token.to_string(),
turn_id: turn_id.to_string(),
project_name: std::path::Path::new(thread_id)
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string),
started_at: started_at_ms,
// 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。
status: "accepted".to_string(),
activity: Some("request-accepted".to_string()),
updated_at: started_at_ms,
sequence: 0,
});
}
Ok(self.append(
thread_id,
DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id),
))
}
/// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。
///
/// 返回是否真的写进去了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退
/// (乱序到达的旧进度)都必须原地丢弃,不能把快照改成过期的样子。
fn update_active_turn(
&mut self,
thread_id: &str,
turn_id: &str,
status: &str,
activity: Option<&str>,
sequence: u64,
updated_at: u64,
) -> bool {
let Some(active) = self
.threads
.get_mut(thread_id)
.and_then(|thread| thread.active_turn.as_mut())
else {
return false;
};
if active.turn_id != turn_id || sequence < active.sequence {
return false;
}
active.status = status.to_string();
active.activity = activity.map(str::to_string);
active.updated_at = updated_at;
active.sequence = sequence;
true
}
/// 首页快照:只导出仍有未收口逻辑回合的 thread。
fn active_turn_snapshots(&self) -> Vec<DirectActiveTurnSnapshot> {
self.threads
.iter()
.filter_map(|(thread_id, thread)| {
let active = thread.active_turn.as_ref()?;
Some(DirectActiveTurnSnapshot {
project_path: thread_id.clone(),
project_name: active.project_name.clone(),
turn_id: active.turn_id.clone(),
started_at: active.started_at,
status: active.status.clone(),
activity: active.activity.clone(),
updated_at: active.updated_at,
sequence: active.sequence,
})
})
.collect()
}
/// 深层的终态出口:解除占用并写下 `turn.completed`。
///
/// 不校验 token:这一条由真正跑完这一轮的代码调用,终态就是它算出来的那个(CLI 这类没有
/// 占用登记的入口也走这里,保持"终态一定下发"的既有语义)。
fn complete_turn(&mut self, thread_id: &str, event: DirectThreadEvent) -> DirectThreadEvent {
if let Some(thread) = self.threads.get_mut(thread_id) {
thread.active_turn = None;
}
self.append(thread_id, event)
}
/// 占用对象的兜底出口:只有当这个 thread 仍被同一个 token 占用时才写。
///
/// 返回是否真的写了。深层已经写出终态时返回 `false`——兜底不覆盖真实结果。
fn complete_turn_if_reserved(
&mut self,
thread_id: &str,
token: &str,
event: DirectThreadEvent,
) -> bool {
let reserved = match self.threads.get_mut(thread_id) {
Some(thread) => match thread.active_turn.as_ref() {
Some(active) if active.token == token => {
thread.active_turn = None;
true
}
_ => false,
},
None => false,
};
if !reserved {
return false;
}
self.append(thread_id, event);
true
}
fn turn_is_active(&self, thread_id: &str) -> bool {
self.threads
.get(thread_id)
.is_some_and(|thread| thread.active_turn.is_some())
}
fn subscriber_ids(&self, thread_id: &str) -> Vec<String> {
self.threads
.get(thread_id)
@@ -377,13 +553,105 @@ pub(crate) fn append_direct_thread_event(
thread_id: &str,
event: DirectThreadEvent,
) -> DirectThreadEvent {
let (event, subscriber_ids) = {
let mut manager = global_direct_thread_manager()
let event = {
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.append(thread_id, event)
};
notify_direct_thread_subscribers(thread_id);
event
}
/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。
/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token:
/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要
/// 不要动当前回合。
pub(crate) fn accept_direct_thread_turn(
thread_id: &str,
token: &str,
turn_id: &str,
user_item_id: Option<&str>,
started_at_ms: u64,
) -> Result<(), String> {
{
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?;
}
notify_direct_thread_subscribers(thread_id);
Ok(())
}
/// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回是否真的写进去了。
pub(crate) fn update_direct_thread_active_turn(
thread_id: &str,
turn_id: &str,
status: &str,
activity: Option<&str>,
sequence: u64,
updated_at: u64,
) -> bool {
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.update_active_turn(thread_id, turn_id, status, activity, sequence, updated_at)
}
/// 首页「运行中的项目」快照:逻辑回合的唯一导出口(见 [`DirectActiveTurnSnapshot`])。
pub(crate) fn list_direct_active_turns() -> Result<Vec<DirectActiveTurnSnapshot>, String> {
let mut turns = global_direct_thread_manager()
.lock()
.map_err(|_| "Direct 线程管理器已损坏".to_string())?
.active_turn_snapshots();
turns.sort_by(|left, right| left.project_path.cmp(&right.project_path));
Ok(turns)
}
/// 深层终态出口:解除占用并写 `turn.completed`。
pub(crate) fn complete_direct_thread_turn(thread_id: &str, event: DirectThreadEvent) {
{
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.complete_turn(thread_id, event);
}
notify_direct_thread_subscribers(thread_id);
}
/// 占用对象的兜底出口:仍被同一 token 占用时才写,返回是否写了。
pub(crate) fn complete_direct_thread_turn_if_reserved(
thread_id: &str,
token: &str,
event: DirectThreadEvent,
) -> bool {
let written = {
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.complete_turn_if_reserved(thread_id, token, event)
};
if written {
notify_direct_thread_subscribers(thread_id);
}
written
}
/// 这个 thread 是否还有没收口的逻辑回合。
pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool {
global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.turn_is_active(thread_id)
}
fn notify_direct_thread_subscribers(thread_id: &str) {
let subscriber_ids = {
let manager = global_direct_thread_manager()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let event = manager.append(thread_id, event);
let subscriber_ids = manager.subscriber_ids(thread_id);
(event, subscriber_ids)
manager.subscriber_ids(thread_id)
};
if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() {
for subscription_id in subscriber_ids {
@@ -394,7 +662,6 @@ pub(crate) fn append_direct_thread_event(
);
}
}
event
}
pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap {
@@ -603,6 +870,34 @@ mod tests {
));
}
/// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started`
/// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。
#[test]
fn failed_turn_completed_replaces_started_anchor() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started(1_000));
manager.append(
"thread-1",
DirectThreadEvent::turn_completed_failed(
crate::agent::DirectTurnFailure::new(
crate::agent::DirectTurnFailureKind::HostDropped,
"回合宿主任务提前结束",
),
FIXED_AT_MS,
),
);
let bootstrap = manager.subscribe("thread-1");
assert!(matches!(
bootstrap.events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, failure, at, .. }]
if status == "failed"
&& failure.as_ref().is_some_and(|failure| failure.kind
== crate::agent::DirectTurnFailureKind::HostDropped)
&& *at == Some(FIXED_AT_MS)
));
}
/// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**,
/// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。
#[test]
@@ -723,4 +1018,79 @@ mod tests {
"unfinished item at queue head blocks middle cleanup"
);
}
/// 首页快照就是逻辑回合的导出:接单即出现、进度按序号回填、收口即消失。
fn snapshot_of(
manager: &DirectThreadManager,
thread_id: &str,
) -> Option<DirectActiveTurnSnapshot> {
manager
.active_turn_snapshots()
.into_iter()
.find(|turn| turn.project_path == thread_id)
}
#[test]
fn active_turn_snapshot_follows_the_logical_turn_lifecycle() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
let thread_id = "/tmp/快照项目";
assert!(snapshot_of(&manager, thread_id).is_none());
manager
.accept_turn(thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS)
.expect("accept");
let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible");
assert_eq!(accepted.turn_id, "turn-1");
assert_eq!(accepted.project_name.as_deref(), Some("快照项目"));
assert_eq!(accepted.started_at, FIXED_AT_MS);
assert_eq!(accepted.status, "accepted");
assert_eq!(accepted.activity.as_deref(), Some("request-accepted"));
assert_eq!(accepted.sequence, 0);
assert!(manager.update_active_turn(
thread_id,
"turn-1",
"streaming",
Some("file-write"),
3,
42,
));
let running = snapshot_of(&manager, thread_id).expect("running turn is visible");
assert_eq!(running.status, "streaming");
assert_eq!(running.activity.as_deref(), Some("file-write"));
assert_eq!(running.sequence, 3);
assert_eq!(running.updated_at, 42);
// 序号倒退与身份对不上的进度都不许改快照。
assert!(!manager.update_active_turn(thread_id, "turn-1", "failed", None, 2, 99));
assert!(!manager.update_active_turn(thread_id, "turn-2", "failed", None, 4, 99));
assert_eq!(
snapshot_of(&manager, thread_id)
.expect("snapshot unchanged")
.status,
"streaming"
);
manager.complete_turn(
thread_id,
DirectThreadEvent::turn_completed("completed".to_string(), 5_000),
);
assert!(snapshot_of(&manager, thread_id).is_none());
}
/// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程
/// 里,界面拿它匹配不了自己发出的那一轮。
#[test]
fn accept_conflict_returns_the_existing_turn_id() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager
.accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS)
.expect("accept");
let conflict = manager
.accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS)
.err();
assert_eq!(conflict.as_deref(), Some("turn-1"));
}
}
@@ -13,6 +13,7 @@
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::agent::DirectTurnFailureKind;
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -211,6 +212,30 @@ impl DirectThreadRequestKind {
}
}
/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
///
/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
/// 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectTurnFailure {
/// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走
/// [`DirectTurnError::wire_kind`]。
pub(crate) kind: DirectTurnFailureKind,
/// 脱敏 + 截断后的失败原因。
pub(crate) message: String,
}
impl DirectTurnFailure {
pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
/// Thread Manager 下发的运行态事件。
///
/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`:
@@ -229,7 +254,8 @@ impl DirectThreadRequestKind {
/// 不能在前端收到或重放时重新取当前时间。
///
/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical
/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身
/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`;
/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身
/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点
/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
@@ -239,7 +265,8 @@ impl DirectThreadRequestKind {
pub(crate) enum DirectThreadEvent {
#[serde(rename = "turn.started")]
TurnStarted {
/// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。
/// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是
/// `turn/start` 的时刻)。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
@@ -250,8 +277,14 @@ pub(crate) enum DirectThreadEvent {
},
#[serde(rename = "turn.completed")]
TurnCompleted {
/// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**,
/// 此时必须带 `failure` 载荷。
status: String,
/// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
/// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
failure: Option<DirectTurnFailure>,
/// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
@@ -301,11 +334,30 @@ impl DirectThreadEvent {
pub(crate) fn turn_completed(status: String, at: u64) -> Self {
Self::TurnCompleted {
status,
failure: None,
at: Some(at),
user_item_id: None,
}
}
/// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。
pub(crate) fn turn_completed_failed(failure: DirectTurnFailure, at: u64) -> Self {
Self::TurnCompleted {
status: "failed".to_string(),
failure: Some(failure),
at: Some(at),
user_item_id: None,
}
}
/// 失败载荷:只有失败终态有。
pub(crate) fn failure(&self) -> Option<&DirectTurnFailure> {
match self {
Self::TurnCompleted { failure, .. } => failure.as_ref(),
_ => None,
}
}
/// 附上本轮开口用户条目的 canonical itemId。
///
/// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底
@@ -317,8 +369,14 @@ impl DirectThreadEvent {
.map(str::to_string);
match self {
Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id },
Self::TurnCompleted { status, at, .. } => Self::TurnCompleted {
Self::TurnCompleted {
status,
failure,
at,
..
} => Self::TurnCompleted {
status,
failure,
at,
user_item_id,
},
@@ -1353,4 +1411,59 @@ mod tests {
);
assert_eq!(item_event.user_item_id(), None);
}
/// 失败终态:`status="failed"` 必须带 `failure{kind,message}`,正常终态不带;载荷跟着身份
/// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。
#[test]
fn turn_completed_carries_failure_payload_only_when_failed() {
let failed = DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new(
crate::agent::DirectTurnFailureKind::ModelFailed,
"上游返回 500:模型服务暂不可用",
),
4_000,
)
.with_user_item_id(Some("direct-codex:turn-1:user"));
assert_eq!(
failed.failure(),
Some(&DirectTurnFailure::new(
crate::agent::DirectTurnFailureKind::ModelFailed,
"上游返回 500:模型服务暂不可用"
))
);
assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user"));
assert_eq!(
serde_json::to_value(&failed).expect("serialize failed turn"),
json!({
"type": "turn.completed",
"status": "failed",
"failure": {"kind": "model-failed", "message": "上游返回 500:模型服务暂不可用"},
"at": 4_000u64,
"userItemId": "direct-codex:turn-1:user",
})
);
assert_eq!(
serde_json::from_value::<DirectThreadEvent>(
serde_json::to_value(&failed).expect("serialize")
)
.expect("round trip"),
failed
);
// 正常终态不带载荷,也不回写 `failure: null`。
let completed = DirectThreadEvent::turn_completed("completed".to_string(), 5_000);
assert_eq!(completed.failure(), None);
assert_eq!(
serde_json::to_value(&completed).expect("serialize completed turn"),
json!({"type": "turn.completed", "status": "completed", "at": 5_000u64})
);
// 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。
let sparse: DirectThreadEvent = serde_json::from_value(json!({
"type": "turn.completed",
"status": "failed",
}))
.expect("failed turn without failure payload");
assert_eq!(sparse.failure(), None);
}
}
@@ -0,0 +1,282 @@
//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。
//!
//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、
//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算,
//! 都没写时由 `Drop` 补一条 `host-dropped`。
//!
//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未
//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见
//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
use uuid::Uuid;
use super::{
accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms,
DirectTurnError, DirectTurnTerminal,
};
/// 一次接单的占用。持有它就代表这一轮还没收口。
///
/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败)
/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾,
/// 新回合不可能插到中间。
pub(crate) struct DirectTurnReservation {
thread_id: String,
token: String,
user_item_id: Option<String>,
}
impl DirectTurnReservation {
/// 接单:登记占用并发出逻辑回合开始事件。
///
/// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。
/// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token`
/// 是两件事:前者来自调用方,后者只活在这个进程里。
///
/// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId`
/// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者
/// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。
pub(crate) fn accept(
thread_id: &str,
client_turn_id: &str,
user_item_id: Option<&str>,
) -> Result<Self, DirectTurnError> {
let token = Uuid::new_v4().to_string();
accept_direct_thread_turn(
thread_id,
&token,
client_turn_id,
user_item_id,
direct_tool_call_now_ms(),
)
.map_err(|existing| DirectTurnError::TurnAlreadyRunning {
existing_invocation_id: existing,
incoming_invocation_id: client_turn_id.to_string(),
})?;
Ok(Self {
thread_id: thread_id.to_string(),
token,
user_item_id: user_item_id.map(str::to_string),
})
}
pub(crate) fn thread_id(&self) -> &str {
&self.thread_id
}
/// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。
///
/// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。
pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool {
complete_direct_thread_turn_if_reserved(
&self.thread_id,
&self.token,
terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()),
)
}
}
impl Drop for DirectTurnReservation {
fn drop(&mut self) {
// 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。
// 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。
let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{
consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread,
DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind,
};
/// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。
fn watch(thread_id: &str) -> String {
let bootstrap = subscribe_direct_thread(thread_id);
let _ = consume_direct_thread(&bootstrap.subscription_id);
bootstrap.subscription_id
}
fn pending(subscription_id: &str) -> Vec<DirectThreadEvent> {
consume_direct_thread(subscription_id)
.expect("consume")
.events
}
fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> {
events
.iter()
.filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. }))
.collect()
}
fn unique_thread(label: &str) -> String {
format!("accept-test-{label}-{}", Uuid::new_v4())
}
#[test]
fn accept_emits_a_logical_turn_started_and_holds_the_turn() {
let thread = unique_thread("started");
let subscription = watch(&thread);
let reservation =
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
let events = pending(&subscription);
assert_eq!(events.len(), 1, "{events:?}");
match &events[0] {
DirectThreadEvent::TurnStarted { user_item_id, .. } => {
assert_eq!(user_item_id.as_deref(), Some("u-1"));
}
other => panic!("expected turn.started, got {other:?}"),
}
assert!(direct_thread_turn_is_active(&thread));
drop(reservation);
}
#[test]
fn a_second_accept_is_rejected_while_the_turn_is_open() {
let thread = unique_thread("busy");
let subscription = watch(&thread);
let reservation =
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
// 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。
assert_eq!(pending(&subscription).len(), 1);
let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2"));
assert!(matches!(
rejected,
Err(DirectTurnError::TurnAlreadyRunning { .. })
));
let events = pending(&subscription);
assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}");
drop(reservation);
}
/// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在
/// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程,
/// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。
#[test]
fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() {
let thread = unique_thread("same-turn-conflict");
let reservation =
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) {
Ok(_) => panic!("同一 thread 的第二条回合必须被拒"),
Err(error) => error,
};
let DirectTurnError::TurnAlreadyRunning {
existing_invocation_id,
incoming_invocation_id,
} = &same_turn
else {
panic!("expected a concurrency rejection, got {same_turn:?}");
};
assert_eq!(existing_invocation_id, "turn-1");
assert_eq!(incoming_invocation_id, "turn-1");
assert!(
same_turn.to_string().contains("同一轮消息仍在处理中"),
"{same_turn}"
);
let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) {
Ok(_) => panic!("同一 thread 的第二条回合必须被拒"),
Err(error) => error,
};
assert!(matches!(
&other_turn,
DirectTurnError::TurnAlreadyRunning {
existing_invocation_id,
incoming_invocation_id,
} if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2"
));
assert!(
other_turn.to_string().contains("另一条 Direct 客户端回合"),
"{other_turn}"
);
drop(reservation);
}
#[test]
fn drop_without_a_terminal_writes_a_host_dropped_terminal() {
let thread = unique_thread("drop");
let subscription = watch(&thread);
let reservation =
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()));
assert!(!direct_thread_turn_is_active(&thread));
// 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。
drop(reservation);
let events = pending(&subscription);
let completed = turn_completed_events(&events);
assert_eq!(completed.len(), 1, "{events:?}");
match completed[0] {
DirectThreadEvent::TurnCompleted {
user_item_id,
failure: Some(failure),
..
} => {
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
assert_eq!(user_item_id.as_deref(), Some("u-1"));
}
other => panic!("expected a failed terminal, got {other:?}"),
}
}
#[test]
fn the_deep_terminal_wins_and_the_fallback_stays_silent() {
let thread = unique_thread("deep");
let subscription = watch(&thread);
let reservation =
DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
// 深层收口:真正跑完这一轮的代码算出来的终态。
let deep = DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new(
DirectTurnFailureKind::Timeout,
"等待模型回执超时".to_string(),
),
2_000,
)
.with_user_item_id(Some("u-1"));
crate::agent::complete_direct_thread_turn(&thread, deep);
assert!(
!reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()),
"深层已收口时兜底不许再写"
);
drop(reservation);
let events = pending(&subscription);
let completed = turn_completed_events(&events);
assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}");
match completed[0] {
DirectThreadEvent::TurnCompleted { failure, .. } => {
assert_eq!(
failure.as_ref().map(|f| f.kind),
Some(DirectTurnFailureKind::Timeout)
);
}
other => panic!("expected a terminal, got {other:?}"),
}
}
#[test]
fn the_thread_can_be_accepted_again_after_the_turn_is_settled() {
let thread = unique_thread("again");
let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept");
drop(first);
let second =
DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")).expect("second accept");
assert!(direct_thread_turn_is_active(&thread));
drop(second);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己
//! 提前收场时补一条失败终态。
//!
//! 这个模块只有三件事,别再往里加第四件:
//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么;
//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。
//!
//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里:
//! 这个模块只负责"什么算失败、原因怎么写"。
//!
//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`);
//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见
//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底",
//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。
use std::path::Path;
use super::{
redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure,
DirectTurnFailureKind,
};
/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于
/// 把整段上游报文塞进事件队列。
const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600;
/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。
pub(crate) struct DirectTurnTerminal {
pub(crate) status: String,
pub(crate) failure: Option<DirectTurnFailure>,
}
impl DirectTurnTerminal {
/// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> DirectThreadEvent {
let event = match self.failure {
Some(failure) => DirectThreadEvent::turn_completed_failed(failure, completed_at),
None => DirectThreadEvent::turn_completed(self.status, completed_at),
};
event.with_user_item_id(user_item_id)
}
}
/// 拿这一轮的**事实**判定终态。判据按优先级:
/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…),
/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步";
/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档:
/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来;
/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾
/// 阶段的账本读不出来时只有它可用。
///
/// **有载荷就一定是 `failed`,没载荷就用收尾阶段的 `session_status`。** 这条反推关系是这个模块存在
/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`,
/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束",
/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。
///
/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏
/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。
pub(crate) fn direct_turn_terminal(
session_status: &str,
collect_outcome: Result<&str, DirectTurnError>,
host_failure: Option<&DirectTurnError>,
history_root: &Path,
) -> DirectTurnTerminal {
let failure = match (host_failure, collect_outcome) {
(Some(failure), _) => Some(failure.clone()),
(None, Err(error)) => Some(error.clone()),
// 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去,
// 不能让界面停在"已结束、没原因"。
(None, Ok(report)) if session_status == "failed" => {
Some(DirectTurnError::TurnFailedUnclassified {
detail: report.to_string(),
})
}
(None, Ok(_)) => None,
};
match failure {
Some(failure) => DirectTurnTerminal::failed(history_root, &failure),
None => DirectTurnTerminal {
status: session_status.to_string(),
failure: None,
},
}
}
impl DirectTurnTerminal {
/// 一次失败终态:`kind` 与 `message` 只在这一个出口从 typed 错误投影。
pub(crate) fn failed(history_root: &Path, failure: &DirectTurnError) -> Self {
Self {
status: "failed".to_string(),
failure: Some(DirectTurnFailure::new(
failure
.wire_kind()
.unwrap_or(DirectTurnFailureKind::ModelFailed),
redact_agent_runtime_error(
history_root,
&failure.to_string(),
DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS,
),
)),
}
}
/// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。
///
/// 这类收场说不出原因,只给分类;能说清原因的一律走 [`Self::failed`]。
pub(crate) fn host_dropped() -> Self {
Self {
status: "failed".to_string(),
failure: Some(DirectTurnFailure::new(
DirectTurnFailureKind::HostDropped,
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{consume_direct_thread, subscribe_direct_thread};
use platform_llm::LlmError;
fn history_root() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/direct-turn-failure-test")
}
/// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。
#[test]
fn non_failure_terminals_keep_the_session_status() {
for status in ["completed", "interrupted", "aborted"] {
let terminal = direct_turn_terminal(status, Ok("报告不重要"), None, &history_root());
assert!(terminal.failure.is_none(), "{status} 不该带失败载荷");
assert_eq!(terminal.status, status);
}
}
/// 拿得到错误:分类与原因都取自错误。
#[test]
fn collect_error_becomes_a_failure_terminal() {
let error = DirectTurnError::from_model_call(&LlmError::Transport(
"DirectProject 收尾历史失败:写入 project.jsonl 失败".into(),
));
let terminal = direct_turn_terminal("completed", Err(error), None, &history_root());
let failure = terminal
.failure
.expect("transport error must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed);
assert!(failure.message.contains("收尾历史失败"));
}
/// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed
/// 错误(原因带 `codex-app-server-error:<kind>` 前缀),宿主收尾自己又把 ledger 阶段推成
/// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影,
/// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。
#[test]
fn projected_native_failure_outranks_the_interrupted_session_status() {
let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest(
"codex-app-server-error:context-window-exceeded".into(),
));
let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root());
let failure = terminal.failure.expect("native failure must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected);
assert_eq!(
failure.message,
"codex-app-server-error:context-window-exceeded"
);
}
/// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底,
/// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。
#[test]
fn unreadable_session_ledger_still_reports_a_payload() {
let terminal = direct_turn_terminal("failed", Ok("报告"), None, &history_root());
assert_eq!(terminal.status, "failed");
let failure = terminal
.failure
.expect("unreadable ledger must fail the turn");
assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed);
assert_eq!(failure.message, "报告");
}
/// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。
#[test]
fn host_recorded_failure_outranks_every_other_source() {
let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\
stderrClass=nonempty;stderrBytes=1000";
let host_failure = DirectTurnError::TransportClosed {
diagnostic: diagnostic.to_string(),
};
let terminal = direct_turn_terminal(
"interrupted",
Ok("执行连接已结束,正在核对自有子进程与在途操作。"),
Some(&host_failure),
&history_root(),
);
let failure = terminal.failure.expect("host fact must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed);
assert!(failure.message.contains("SIGKILL"));
assert!(!failure.message.contains("正在核对自有子进程"));
// 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。
let error = DirectTurnError::from_model_call(&LlmError::Transport(
"DirectProject 收尾历史失败".into(),
));
let host_failure = DirectTurnError::TurnInterrupted {
detail: "本轮模型执行被中断".into(),
};
let terminal = direct_turn_terminal(
"interrupted",
Err(error),
Some(&host_failure),
&history_root(),
);
let failure = terminal.failure.expect("host fact must fail the turn");
assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted);
assert!(failure.message.contains("本轮模型执行被中断"));
}
/// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
#[test]
fn terminal_event_carries_the_payload_and_the_opening_identity() {
let error = DirectTurnError::from_model_call(&LlmError::Upstream {
status_code: 502,
message: "上游 502".into(),
});
let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root());
let event = failing.event(2_000, Some("direct-codex:turn-1:user"));
assert_eq!(
event.failure().map(|failure| failure.kind),
Some(DirectTurnFailureKind::ModelFailed)
);
assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user"));
assert_eq!(event.at(), Some(2_000));
let quiet = direct_turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root());
let event = quiet.event(3_000, None);
assert!(event.failure().is_none());
assert!(matches!(
event,
DirectThreadEvent::TurnCompleted { ref status, .. } if status == "completed"
));
}
/// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。
#[test]
fn host_dropped_terminal_only_carries_the_classification() {
let terminal = DirectTurnTerminal::host_dropped();
assert_eq!(terminal.status, "failed");
let failure = terminal.failure.expect("host-dropped must fail the turn");
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
assert!(!failure.message.trim().is_empty());
}
}
@@ -32,6 +32,8 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag
#[derive(Clone)]
pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
project_path: String,
/// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。
thread_id: String,
turn_id: String,
sequence: Arc<AtomicU64>,
}
@@ -40,6 +42,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
pub(crate) fn new(root: &Path, turn_id: String) -> Self {
Self {
project_path: root.to_string_lossy().into_owned(),
thread_id: crate::agent::direct_thread_id_for_project(root),
turn_id,
sequence: Arc::new(AtomicU64::new(0)),
}
@@ -136,8 +139,8 @@ impl DirectGameCreatorTurnUpdateEmitter {
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64;
update_direct_active_turn(
Path::new(&self.project_path),
update_direct_thread_active_turn(
&self.thread_id,
&self.turn_id,
status,
activity,

Some files were not shown because too many files have changed in this diff Show More