Skip to content

Fix/studio migration analysis protocol - #1144

Merged
wujiaming-ai merged 16 commits into
mainfrom
fix/studio-migration-analysis-protocol
Sep 23, 2026
Merged

wujiaming-ai merged 16 commits into
mainfrom
fix/studio-migration-analysis-protocol

Conversation

@wujiaming-ai

Copy link
Copy Markdown
Collaborator

No description provided.

Garming and others added 16 commits September 20, 2026 18:01
Route analysis read its result out of the Codex text stream, so a progress
update or a Markdown-fenced reply could be mistaken for the contract and fail
the whole migration.  Deliver it through a registered dynamic tool instead: the
payload stays in typed JSON-RPC arguments, a rejected result returns as
`success: false` so the same turn can correct itself, and a second prompt retries
the contract when the first turn produces nothing usable.

The turn also no longer runs inside the upload request.  Studio starts a
background worker, records a lease in `control/analysis-driver.json`, and keeps
its heartbeat fresh while Codex works; a later read hands the attempt back to the
in-Sandbox script once the lease goes stale, so a Studio restart cannot leave a
task analysing forever.  The scripted path stays as the fallback and
`AGENTKIT_MIGRATION_APP_SERVER=0` pins it, which is what lets the app-server path
run by default.

- app_server: validate dynamic-tool arguments against the analysis contract,
  answer rejections with the failing field, and end the turn once the result is in
- codex_app_server: register dynamic tools, retry overloaded turns, and keep a
  `willRetry` notice from ending the current turn
- service: start both drivers behind one status contract, recover stalled
  app-server attempts on read, scan the scripted output for the newest contract
  JSON, and report diagnostics through the veadk logger (the Studio entrypoint
  pins the root logger to ERROR, which hid a silent fallback)
Evaluation judging no longer parses the last `codex exec` agent message: the
runner writes a durable `judge/request.json` for the batch it is judging and
Studio answers it with one Codex app-server turn that returns the verdict
through the `reportEvaluation` dynamic tool. The runner keeps owning batching,
the thread record, the batch cache and the report, and still validates the
verdict before caching it, so a rejected batch falls back to `codex exec`
inside the same batch budget. Setting `AGENTKIT_MIGRATION_JUDGE_APP_SERVER=0`
pins that scripted judge for the whole run instead of writing requests.

The request declares how long the runner will listen, and the turn is sized to
answer inside that window, so a batch that is too slow comes back as an
answered failure instead of a verdict nobody reads. Two defects came out of the
first real runs and are fixed here: the turn is now bounded by wall-clock time
instead of the app-server's inactivity window, and a transport failure is
answered with an error envelope instead of leaving the runner waiting. The
`codex exec` fallback also uses the budget left after the channel rather than
the budget the batch started with.

`run_tool_turn` carries the shared one-turn contract for both the route
analysis and the judge, and the analysis now uses it too.

Verified with a real migration-evaluation run (Dify workflow source, Dev
Sandbox): the channel delivered the verdict in 285.9s, the runner accepted it
without falling back, and the report matches the legacy shape field for field.
…erver turns

The migration workspace polled the Sandbox, re-ran the whole analysis to ask the user
a question, and left a killed delivery hanging on a lease nobody watched. This puts
the long work on Studio-driven Codex turns and gives the page a stream to follow.

- Stream the task: an append-only event log plus SSE replaces polling, so a refresh
  or a second tab resumes from a cursor and progress no longer needs an open page
  (`events.py`, `routes.py`, `MigrationWorkspace.tsx`).
- Answer in place: the analysis turn registers its own `askUser` dynamic tool and
  waits for the page, so one attempt finishes the analysis instead of a `needs_input`
  re-run (`analysis_input.py`, `models.py`, `analysis_input` routes).
- Lease the delivery: the in-Sandbox driver publishes a heartbeat lease, and a killed
  run settles as `MIGRATION_DELIVERY_INTERRUPTED` instead of hanging forever.
- Close the delivery on a turn: `publishArtifact` re-reads the delivered zip and
  requires it to match the CLI manifest, and `reportDelivery` may only explain the
  state the Sandbox published, never turn a failure into a success
  (`delivery_turn.py`, `contracts.py`, `service.py`).
- Show app-server work: turn events are translated back into the `codex exec --json`
  shape the page already parses, so the activity feed is no longer empty on the
  app-server driver (`activity.py`).

Verified: `pytest tests/frontend -k migration` (517 passed), `node --test
frontend/tests/*.test.mjs`, `npx tsc --noEmit`, `ruff check .`, and local end-to-end
runs for the analysis, question, delivery and killed-driver paths.
… stream

The migration workspace rendered its Codex events as a plain grid of blocks,
so an analysis or a migration run read as a different product from the
intelligent build it sits next to.

- The activity feed now uses `<Blocks groupProcess streaming>`: tool calls fold
  into expandable process rows while a turn is running and settle when it ends.
  The stream container drops its own `grid` gap, which the grouped rows supply.
- The confirm-migration card loses the "Migration starts only after
  confirmation" subtitle; the controls below already say what happens.
- `NewChatCompactSelect` measures the room it has when it opens: a list that
  cannot fit below the trigger flips upwards and shrinks to the space it
  actually has, so a select near the bottom of a card no longer pushes its
  options past the card edge. It re-measures on resize and scroll.
A finished migration showed "migration complete, verification passed" and then
started a second turn that pulled the artifact back. Two things caused it.

`publishArtifact` ran inside the closing turn, but `AnalysisActivityLog`
discarded every `dynamicToolCall`, so the page showed reasoning and `cat`
commands while the actual hand-over was invisible. The delivery turn is the
exception to that rule: its tool call *is* the deliverable, so
`include_dynamic_tools` records it with the same item shape the intelligent
build uses for its result tool, and `complete_dynamic_tools` closes a row the
turn ended on before the app-server reported it, which would otherwise leave a
running row for a delivery that already landed.

The verdict schema then offered all four delivery states and invited a wrong
pick, so the turn had to be retried. The schema is now narrowed to the state
the Sandbox already published, and the handler keeps its server-side check.
The migration page had already switched to the intelligent build's grouped
process rows, but the blocks it built from migration items carried none of the
fields that renderer reads. Every Codex row therefore fell back to the generic
look: a generic icon, output cut at 2000 characters, no timing in the collapsed
header and no computed label. The missing half was the data, not the component.

- The activity log keeps the app-server's phase, duration and commandActions,
  and the activity items carry the native itemType/durationMs/phase the shared
  row renderer keys off.
- Tool rows keep the name the app-server gives them (运行命令 / 修改文件 /
  MCP · server/tool / 网络搜索), so the shared label rules produce the intelligent
  build's wording. The migration's own status titles stay as the fallback for the
  codex exec driver, and its delivery tools keep their migration wording.
- A tool_output line is often the last line for a call, so it now carries the
  name and command of the call it belongs to; without them a running command fell
  back to the migration's wording and lost its label mid-flight.
- The feed reports the run's live status, and stays streaming while Codex still
  has a running item, which includes a closing turn that lands after the task
  already settled.
The question window waits on a `concurrent.futures.Future` through
`asyncio.to_thread`, but only caught the built-in `TimeoutError`.  On 3.10
`asyncio` converts that future's timeout while chaining it back across the
thread boundary (`_convert_future_exc`), so the exception escaped the guard,
killed the analysis worker and the turn never delivered its questions.

Catch the app-server-level timeout the way the rest of the app-server client
does.  3.11+ aliases both names to the built-in class, so this stays a no-op
there, and the existing "nobody answers" check is the regression test.
The migration page showed what Codex did but never what it cost: no turn
duration, no tool time, no tokens. The intelligent build reports all three
from the app-server's turn lifecycle, and the migration feed renders the
same summary component, so the reader now settles one turn per source.

A tool-driven migration turn ends the moment its result lands, which is why
the app-server never saw turn_completed and the usage stayed invisible.
Turns now settle from the app-server's own turn record read back before the
session closes, and the in-Sandbox `codex exec --json` turn — which writes a
bare terminal line with snake_case usage and no timestamps — settles from
that line, with the total computed the way the app-server reports it.

The sandbox exec stream carries no timing at all, so those rows say the
duration was not reported rather than inventing one.
The migration page could not report the one turn it is watched for most:
`ak migrate` drives Codex itself with `codex exec --json`, and that stream
carries no tool duration, no turn duration and no model, so those rows said
the numbers were not reported while the intelligent build, the analysis turn
and the delivery turn all reported them.

The CLI is closed source, but it calls whatever `codex` is on PATH, and the
Sandbox already hosts a Codex app-server for the Session. Starting a
migration now installs a shim ahead of `codex` on that command's PATH. It
answers only the CLI's `codex exec … --json` form — including `resume <id>`,
`resume --last`, and a prompt on stdin — drives it as one app-server turn,
and writes the app-server's own items back as the `codex exec --json` lines
the page already reads: each tool's `duration_ms`, every item's native name,
and a closing turn line carrying `startedAt`/`completedAt`/`durationMs` and
the model, which `codex exec` can never write.

Anything the shim does not recognise, and any failure to reach the
app-server before the first log line, goes to the real Codex with its
original argv, so the CLI keeps behaving exactly as it did before. The
prompt is only read from stdin once the app-server is going to take the
turn, because a fallback still has to find it there.

Also fixes three shim defects found while testing: a connection lost
mid-turn left in-flight requests waiting forever, a usage update carrying
only the per-turn increment was dropped, and closing the connection could
raise over the turn's own result.
…opwatch

`test_sync_tool_calls_use_thread_pool_when_configured` failed on CI with
`0.358879437999974 < 0.35`. Two tool calls that each sleep 0.2 s take about
0.2 s when handed to a thread pool and about 0.4 s when run on the caller's
thread, so the 0.35 s bound sat only 0.15 s above the outcome it wanted and
a loaded runner spent that on overhead. It failed for no reason a reader
could act on, and it blocks the merge whenever the machine is busy.

What the tests are actually after is whether the two calls were in flight
at the same time. `_run_two_sync_tool_calls` now records each call's window
and the assertions check the windows overlap (or do not), which holds no
matter how slow the machine is.
The migration CLI validates the output after `codex exec` exits and opens a
new attempt when a finding blocks the delivery, so a migration turn could
claim the migration was done and still be followed by a second one: the
report lives past the turn boundary, and the model's own completion claim
was never checked.

The shim already owns the exec, so it now runs the CLI's own deterministic
contract (`scripts/validate_runtime.sh`, the same script the migration
prompt tells the model to run) as soon as the turn ends. A fatal or
repairable finding is handed straight back into the same thread as repair
instructions, and the turn only closes once the contract passes. The repair
budget is two rounds, a contract the shim cannot run or read a verdict from
never interferes, and the CLI's attempt loop stays the backstop. One exec
still reads as one migration turn on the page, and its elapsed time now
covers every sub-turn and the validation between them.

The migration instruction also states the rule the first attempt broke:
findings block completion, and the `ak init` config baseline is not the
model's to rewrite.
The analysis turn moved from `codex exec --output-schema` to a Sandbox
app-server dynamic tool, where the JSON Schema is only a declaration: a turn
that answered with a progress note, a Markdown fence or a probe-shaped payload
reached Studio's verifier anyway, and every rejection collapsed into one
sentence that named neither the field nor the fix.  The first submission that
passed also became final, so the failing production run turned a plain Dify
export into `MIGRATION_ANALYSIS_UNSUPPORTED` while the deterministic detector
the image ships had silently found nothing (PyYAML lives on the 3.12
interpreter, the detector asked `/usr/bin/python3`).

Studio now reads the archive itself before the turn: `migration/detection.py`
walks the upload, parses whatever DSL is inside and records the files, the
framework candidates and the evidence lines it actually saw, with a degraded
report instead of a silent empty one.  The turn gets one tool per verdict
(`reportRecommendation`, `reportNeedsInput`, `reportUnsupported`) and
`migration/analysis_contract.py` assembles the stored document, so the model's
side of the contract is flat and forgiving: summary is the only required
field, unknown keys, unknown frameworks and odd types are folded into notes,
scalar values are normalised, and the detected candidates are always merged
in.  The bar that stays is the one that protects the user: `unsupported`
needs two evidence lines that exist in the detected file list, and it fails
open when detection itself is degraded.

When a turn delivers nothing usable, Studio answers from the detection report
(a conservative recommendation, the detected framework first) instead of
spending the scripted driver on a second analysis, and the outcome is written
to `diagnostics/analysis/model-turn.json` so the reason is readable on the
page.

The two real Baidu Qianfan and Dify exports that failed in production now
reach `analysis_ready` with model-authored recommendations, and the seven
payloads from the failed rollout are kept as regression cases.
A migration run can lose its AgentKit CLI after the Codex turn reported a
terminal state: the project is finished on disk, but nothing will ever turn
it into an artifact, so the task sits in `migrating` forever.

Packaging is a pure function of the files on disk, so Studio now does that
half itself instead of asking for another fifteen-minute Codex turn:

* `delivery_recovery.py` mirrors the CLI's own packaging step - the same
  file set, exclusions, secrets refusal, manifest, atomic zip and
  `migration-status.json` - and is run inside the Sandbox by the read path
  when the driver is gone and the agent finished.
* the driver heartbeat now watches the CLI it speaks for and publishes
  `lost` the moment that process leaves the Sandbox, instead of waiting for
  the heartbeat to go stale.
* the rebuild lease is held by a Studio process and heartbeated, so a
  rebuild that dies is retried once rather than never.
`License-Check` has been the only red job on this branch: four files added
along the way went in without the Apache header every other `.py` in the
tree carries.
The allowlist that migration and intelligent build validate against now
carries `doubao-seed-2-1-pro-260915` in place of the `260628` build, and
gains `deepseek-v4-1-flash-260910`.

The Sandbox tools are provisioned with `VOLCENGINE_STUDIO_AGENT_MODEL_NAME`,
and that name is what the read path validates when the caller does not pick a
model, so the default moves with the list: leaving it behind would reject
every run that does not name a model explicitly.
Only the VolcEngine side of this change was asked for; `deepseek-v4-1-flash-260910`
does not go on the BytePlus list.
@wujiaming-ai
wujiaming-ai merged commit 763865d into main Sep 23, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants