fix(daemon): treat media generate handoff as success (#1715)

This commit is contained in:
Quang Do 2026-05-15 13:11:40 +07:00 committed by GitHub
parent cd3acda6f6
commit 3d0e708720
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 54 additions and 18 deletions

View file

@ -399,7 +399,9 @@ async function runMediaGenerate(rawArgs) {
process.exit(4);
}
console.error(`task ${taskId} queued (${accepted.status || 'queued'})`);
await pollUntilDoneOrBudget(daemonUrl, taskId, 0);
await pollUntilDoneOrBudget(daemonUrl, taskId, 0, {
stillRunningExitCode: 0,
});
}
async function runMediaWait(rawArgs) {
@ -428,9 +430,13 @@ async function runMediaWait(rawArgs) {
await pollUntilDoneOrBudget(daemonUrl, taskId, since);
}
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart) {
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) {
const totalBudgetMs = 25_000;
const perCallTimeoutMs = 4_000;
const stillRunningExitCode =
typeof options.stillRunningExitCode === 'number'
? options.stillRunningExitCode
: 2;
const startedAt = Date.now();
const url = `${daemonUrl.replace(/\/$/, '')}/api/media/tasks/${encodeURIComponent(taskId)}/wait`;
@ -520,12 +526,16 @@ async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart) {
elapsed: Math.round((Date.now() - startedAt) / 1000),
};
process.stdout.write(JSON.stringify(handoff) + '\n');
const stillRunningHint =
stillRunningExitCode === 0
? 'This is a successful queued/running handoff, not a failure.'
: `exit code ${stillRunningExitCode} = still running.`;
process.stderr.write(
`task ${taskId} still running after ${handoff.elapsed}s. ` +
`Run \`"$OD_NODE_BIN" "$OD_BIN" media wait ${taskId} --since ${since}\` to continue in an agent runtime ` +
`(exit code 2 = still running).\n`,
`(${stillRunningHint}).\n`,
);
process.exit(2);
process.exit(stillRunningExitCode);
}
function surfaceFetchError(err, daemonUrl) {

View file

@ -184,11 +184,13 @@ the same turn.
### Long-running renders (Volcengine i2v, hyperframes-html): generate wait loop
\`media generate\` no longer blocks for the full render. It dispatches
the task daemon-side and returns within ~1s with a \`{taskId}\`. You then
the task daemon-side and either returns the finished \`{"file":{...}}\`
or returns a successful queued/running handoff with \`{taskId}\`. You then
drive the render to completion by calling \`media wait <taskId>\` through \`OD_NODE_BIN\` + \`OD_BIN\` in
a loop each call long-polls the daemon for up to 25s, well below your
shell tool's default 30s timeout. The wait subcommand exits with a
distinct code per outcome:
shell tool's default 30s timeout. \`media generate\` treats the handoff as
exit \`0\` so the first dispatch does not look like a failed shell call.
The wait subcommand exits with a distinct code per outcome:
- \`exit 0\` — terminal **done**. Final stdout line is \`{"file":{...}}\`.
- \`exit 5\` — terminal **failed**. Stderr carries the upstream error.
@ -203,18 +205,22 @@ The pattern in your shell tool:
\`\`\`bash
out=$("$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model --image )
ec=$?
if [ "$ec" -ne 0 ] && [ "$ec" -ne 2 ]; then
if [ "$ec" -ne 0 ]; then
echo "$out" >&2; exit "$ec"
fi
task_id=$(printf '%s\\n' "$out" | tail -1 | jq -r '.taskId // empty')
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // 0')
while [ "$ec" -eq 2 ] && [ -n "$task_id" ]; do
while [ -n "$task_id" ]; do
out=$("$OD_NODE_BIN" "$OD_BIN" media wait "$task_id" --since "$since")
ec=$?
since=$(printf '%s\\n' "$out" | tail -1 | jq -r '.nextSince // '"$since")
if [ "$ec" -eq 0 ]; then
task_id=""
elif [ "$ec" -ne 2 ]; then
echo "$out" >&2; exit "$ec"
fi
done
# At this point ec is 0 (done) or 5 (failed). Final result on the last
# stdout line of \`out\`.
# At this point ec is 0 (done). Final result on the last stdout line of \`out\`.
\`\`\`
Each \`generate\` and \`wait\` call lasts at most ~25s, so the agent
@ -325,13 +331,16 @@ do **not** narrate a stub as if it were the final result.
models without a real renderer, and the CLI prints the daemon's
error message. Set \`OD_MEDIA_ALLOW_STUBS=1\` to write a labelled
placeholder instead.
2. **Exit code.** \`"$OD_NODE_BIN" "$OD_BIN" media generate\` and \`"$OD_NODE_BIN" "$OD_BIN" media wait\` exit:
\`0\` on real success, \`2\` when the task is **still running** and
needs another \`wait\` call (see "Long-running renders" above), \`5\`
when the daemon accepted the request but the provider call failed
(key missing / 4xx / network blip), and \`14\` for client / daemon
errors. Always check \`$?\` before describing the output. \`2\` is
not a failure it just means "keep polling".
2. **Exit code.** \`"$OD_NODE_BIN" "$OD_BIN" media generate\` exits \`0\` for
both immediate completion and successful queued/running handoff; inspect
the final stdout JSON for either \`file\` or \`taskId\`. \`"$OD_NODE_BIN"
"$OD_BIN" media wait\` exits \`0\` on terminal **done**, \`2\` when the
task is still **running** and needs another \`wait\` call (see
"Long-running renders" above), \`5\` when the daemon accepted the request
but the provider call failed (key missing / 4xx / network blip), and
\`14\` for client / daemon errors. Always check \`$?\` before describing
the output. \`2\` from \`media wait\` is not a failure — it just means
"keep polling".
3. **stderr WARN lines.** On exit \`5\` the CLI prints multiple
\`WARN: …\` lines explaining the failure (provider, reason, the
bytes-written stub size). Quote the reason in your reply.

View file

@ -328,6 +328,23 @@ describe('composeSystemPrompt — metadata.promptTemplate', () => {
expect(out).not.toContain('fishaudio, …) are still stubs');
});
it('documents media generate handoffs as successful queued results', () => {
const out = composeSystemPrompt({
metadata: {
kind: 'video',
videoModel: 'seedance-2.0',
videoAspect: '16:9',
videoLength: 5,
},
});
expect(out).toContain('`media generate` treats the handoff as');
expect(out).toContain('exit `0` so the first dispatch does not look like a failed shell call');
expect(out).toContain('`"$OD_NODE_BIN" "$OD_BIN" media generate` exits `0`');
expect(out).toContain('either `file` or `taskId`');
expect(out).toContain('`2` from `media wait` is not a failure');
});
it('surfaces ElevenLabs voice options for project discovery when no voice was preselected', () => {
const voiceOptions = Array.from({ length: 50 }, (_, index) => {
const ordinal = index + 1;