@remixmate/cli 0.9.27 → 0.9.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ export declare function traceHeaders(): Record<string, string>;
2
+ export declare function cleanDiagnosticText(value: string): string;
3
+ /** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
4
+ export declare function diagnostic(exitCode: number, durationMs: number, error?: unknown): void;
@@ -0,0 +1,39 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ export function traceHeaders() {
3
+ const parent = process.env.AB_TRACEPARENT ?? '';
4
+ const match = /^00-([a-f0-9]{32})-([a-f0-9]{16})-[a-f0-9]{2}$/.exec(parent);
5
+ const trace = match && !/^0+$/.test(match[1]) && !/^0+$/.test(match[2]) ? match[1] : randomBytes(16).toString('hex');
6
+ const header = `00-${trace}-${randomBytes(8).toString('hex')}-01`;
7
+ process.env.AB_TRACEPARENT = header;
8
+ process.env.AB_OPERATION_ID ||= randomUUID();
9
+ return { traceparent: header };
10
+ }
11
+ export function cleanDiagnosticText(value) {
12
+ let text = value.replace(/https?:\/\/[^\s"'<>]+/g, raw => {
13
+ try {
14
+ const url = new URL(raw);
15
+ return url.origin + url.pathname;
16
+ }
17
+ catch {
18
+ return '[url]';
19
+ }
20
+ }).replace(/(Bearer\s+|(?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s,;}]+/gi, '$1[redacted]');
21
+ for (const [key, secret] of Object.entries(process.env)) {
22
+ if (/TOKEN|SECRET|PASSWORD|API_KEY/i.test(key) && secret && secret.length >= 6)
23
+ text = text.split(secret).join('[redacted]');
24
+ }
25
+ return Buffer.from(text).subarray(0, 2048).toString('utf8');
26
+ }
27
+ /** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
28
+ export function diagnostic(exitCode, durationMs, error) {
29
+ if (process.env.AB_DIAGNOSTICS !== '1')
30
+ return;
31
+ traceHeaders();
32
+ const outcome = exitCode === 0 ? 'success' : exitCode === 130 ? 'cancelled' : [2, 3, 4].includes(exitCode) ? 'rejected' : 'failure';
33
+ const record = { schema_version: 1, timestamp: new Date().toISOString(), service: 'remixmate-cli', environment: process.env.APP_ENV ?? 'dev',
34
+ level: outcome === 'failure' ? 'error' : 'info', event: 'cli.execution.completed', message: 'CLI execution completed', outcome,
35
+ trace_id: process.env.AB_TRACEPARENT?.split('-')[1], operation_id: process.env.AB_OPERATION_ID,
36
+ duration_ms: durationMs, attributes: { exit_code: exitCode },
37
+ error: error instanceof Error ? { type: error.name, message: cleanDiagnosticText(error.message), stack: cleanDiagnosticText(error.stack ?? '') } : undefined };
38
+ process.stderr.write('__diagnostic_v1__ ' + JSON.stringify(record) + '\n');
39
+ }
package/dist/http.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * 3. process.env.MM_BACKEND_API_URL — ab-agent's convention (back-compat)
19
19
  * 4. https://api.remixmate.com/api — production default (zero-config)
20
20
  */
21
+ import { traceHeaders, cleanDiagnosticText } from './diagnostics.js';
21
22
  import { recordBilling } from './billing.js';
22
23
  import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './auth/resolve.js';
23
24
  import { attemptAutoLogin } from './auth/auto-login.js';
@@ -73,7 +74,7 @@ function buildHeaders(ctx, extra) {
73
74
  headers['x-invoke-agent'] = ctx.agentName;
74
75
  if (ctx.conversationId)
75
76
  headers['x-conversation-id'] = ctx.conversationId;
76
- return { ...headers, ...extra };
77
+ return { ...headers, ...extra, ...traceHeaders() };
77
78
  }
78
79
  /**
79
80
  * POST JSON to ab-api and return the parsed business payload.
@@ -110,16 +111,16 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
110
111
  throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 HTTP ${resp.status})`, EXIT.NOT_AUTHENTICATED);
111
112
  }
112
113
  if (resp.status >= 500) {
113
- throw new SkillError(`❌ API request failed (HTTP ${resp.status}): ${text}`, EXIT.BACKEND_UNREACHABLE);
114
+ throw new SkillError(`❌ API request failed (HTTP ${resp.status}): [response omitted]`, EXIT.BACKEND_UNREACHABLE);
114
115
  }
115
- throw new SkillError(`❌ API request failed (HTTP ${resp.status}): ${text}`);
116
+ throw new SkillError(`❌ API request failed (HTTP ${resp.status}): [response omitted]`);
116
117
  }
117
118
  let parsed;
118
119
  try {
119
120
  parsed = JSON.parse(text);
120
121
  }
121
122
  catch {
122
- throw new SkillError(`❌ failed to parse response, body is not JSON: ${text.slice(0, 200)}`);
123
+ throw new SkillError(`❌ failed to parse response, body is not JSON: [response omitted]`);
123
124
  }
124
125
  // Before the code check: a charged response is always code=0 today, but a
125
126
  // partial-failure envelope that still billed must not lose its billing line.
@@ -130,7 +131,7 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
130
131
  if (parsed.code === 401 || parsed.code === 403) {
131
132
  throw new SkillError(`${NOT_AUTHENTICATED_HINT}\n (后端返回 code=${parsed.code}${parsed.msg ? `: ${parsed.msg}` : ''})`, EXIT.NOT_AUTHENTICATED);
132
133
  }
133
- throw new SkillError(`❌ API returned a business error: ${parsed.msg ?? 'unknown error'} (code=${parsed.code})`);
134
+ throw new SkillError(`❌ API returned a business error: ${cleanDiagnosticText(parsed.msg ?? 'unknown error')} (code=${parsed.code})`);
134
135
  }
135
136
  return parsed.data ?? undefined;
136
137
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.27",
4
- "generatedAt": "2026-09-13T09:11:30.410Z",
3
+ "version": "0.9.29",
4
+ "generatedAt": "2026-09-14T08:49:05.207Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
package/dist/runner.js CHANGED
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Resolves to the child process's exit code so cli.ts can propagate it.
11
11
  */
12
+ import { diagnostic, cleanDiagnosticText, traceHeaders } from './diagnostics.js';
12
13
  import { spawn } from 'node:child_process';
13
14
  import path from 'node:path';
14
15
  import { findSkill, SKILLS_DIR } from './registry.js';
@@ -24,9 +25,14 @@ function tokenFlag(args) {
24
25
  return typeof value === 'string' ? value : undefined;
25
26
  }
26
27
  export async function runSkill(skillName, opts) {
28
+ const started = performance.now();
29
+ let exitCode = EXIT.ERROR;
30
+ let failure;
31
+ traceHeaders();
27
32
  const skill = findSkill(skillName, opts.baseDir ?? SKILLS_DIR);
28
33
  if (!skill) {
29
34
  process.stderr.write(`❌ skill not found: ${skillName}\n`);
35
+ diagnostic(EXIT.USAGE, performance.now() - started);
30
36
  return EXIT.USAGE;
31
37
  }
32
38
  try {
@@ -40,24 +46,26 @@ export async function runSkill(skillName, opts) {
40
46
  case 'python':
41
47
  // Python children talk to ab-api themselves and print their own billing
42
48
  // footer; nothing was charged through this process.
43
- return await runPython(skill, opts.rawArgs, auth);
49
+ return exitCode = await runPython(skill, opts.rawArgs, auth);
44
50
  case 'http':
45
51
  case 'builtin':
46
- return await runHandler(skill, opts.parsedArgs, auth);
52
+ return exitCode = await runHandler(skill, opts.parsedArgs, auth);
47
53
  }
48
54
  }
49
55
  catch (err) {
56
+ failure = err;
50
57
  if (err instanceof SkillError) {
51
- process.stderr.write(err.message + '\n');
52
- return err.exitCode;
58
+ process.stderr.write(cleanDiagnosticText(err.message) + '\n');
59
+ return exitCode = err.exitCode;
53
60
  }
54
- process.stderr.write(`❌ unexpected error: ${err.message}\n`);
61
+ process.stderr.write(`❌ unexpected error: ${cleanDiagnosticText(err.stack ?? err.message)}\n`);
55
62
  return EXIT.ERROR;
56
63
  }
57
64
  finally {
58
65
  // In `finally` because a run can fail *after* a billed step (e.g. the image
59
66
  // generated and was charged, then the upload timed out) — spend gets
60
67
  // reported either way. No-ops when nothing was charged.
68
+ diagnostic(exitCode, performance.now() - started, failure);
61
69
  emitBillingFooter();
62
70
  }
63
71
  }
@@ -140,7 +148,7 @@ async function applyTakeContext(skill, opts, auth) {
140
148
  `(project ${take.projectId}${take.attempt ? `, attempt ${take.attempt}` : ''})\n`);
141
149
  }
142
150
  catch (err) {
143
- process.stderr.write(`⚠️ 无法归属本次产物(内容仍会正常生成,但不会出现在项目里): ${err.message}\n`);
151
+ process.stderr.write(`⚠️ 无法归属本次产物(内容仍会正常生成,但不会出现在项目里): ${cleanDiagnosticText(err.message)}\n`);
144
152
  }
145
153
  }
146
154
  async function runPython(skill, rawArgs, auth) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,6 +25,7 @@
25
25
  "test:template-pipeline": "PYTHONDONTWRITEBYTECODE=1 node dist/cli.js exec -- python3 scripts/test-template-pipeline.py",
26
26
  "test:props-contract": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-props-contract.py",
27
27
  "test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
28
+ "test:render-resume": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-resume.py",
28
29
  "test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
29
30
  "test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
30
31
  "test:contracts": "PYTHONDONTWRITEBYTECODE=1 python3 skills/template-registry/scripts/check_contracts.py",
@@ -34,6 +34,9 @@ import urllib.error
34
34
  import urllib.parse
35
35
  import urllib.request
36
36
  from typing import Callable, Optional
37
+ from pathlib import Path
38
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry" / "scripts"))
39
+ from log_diagnostics import trace_headers
37
40
 
38
41
  DEFAULT_API_BASE_URL = "https://api-render.remixmate.com"
39
42
  API_BASE_URL = (
@@ -61,6 +64,19 @@ class RemoteRenderError(RuntimeError):
61
64
  """抛出至调用方,由上层写入 render_plan.errors。"""
62
65
 
63
66
 
67
+ class RemoteRenderTimeout(RemoteRenderError):
68
+ """轮询超时 —— 任务**没有失败**,只是我们不等了。
69
+
70
+ 与普通 RemoteRenderError 分开,是因为上层的处置完全相反:
71
+ 真失败 → 可以重新提交一个任务;超时 → 后端还在渲,重新提交等于白烧一遍,
72
+ 必须保留 taskId 让下一次调用续上。
73
+ """
74
+
75
+ def __init__(self, message: str, task_id: str):
76
+ super().__init__(message)
77
+ self.task_id = task_id
78
+
79
+
64
80
  def _build_headers(private_token: str, content_type: str = "application/json", conversation_id: Optional[str] = None) -> dict:
65
81
  if not private_token:
66
82
  raise RemoteRenderError("PrivToken is not set (PRIV_TOKEN or --priv-token)")
@@ -74,6 +90,7 @@ def _build_headers(private_token: str, content_type: str = "application/json", c
74
90
  headers["x-invoke-agent"] = AGENT_NAME
75
91
  if conversation_id:
76
92
  headers["x-conversation-id"] = conversation_id
93
+ headers.update(trace_headers())
77
94
  return headers
78
95
 
79
96
 
@@ -174,9 +191,10 @@ def poll_render(
174
191
  while True:
175
192
  elapsed = time.monotonic() - start
176
193
  if elapsed > timeout:
177
- raise RemoteRenderError(
194
+ raise RemoteRenderTimeout(
178
195
  f"remote render polling timed out (waited {elapsed:.0f}s, taskId={task_id}); "
179
- f"the job may still be running on the backend — query {status_path} later"
196
+ f"the job may still be running on the backend — query {status_path} later",
197
+ task_id,
180
198
  )
181
199
 
182
200
  try:
@@ -1647,6 +1647,23 @@ def render_with_local_cli(render_plan: dict, output_path: str) -> bool:
1647
1647
  return False
1648
1648
 
1649
1649
 
1650
+ def _persist_plan_checkpoint(render_plan: dict, job_id: Optional[int], private_token: str) -> None:
1651
+ """把当前 RenderPlan 存个档(尽力而为,失败只记日志)。
1652
+
1653
+ 存在的唯一理由:远端 taskId 必须在**渲染结束之前**就落库。脚本可能在任何时刻
1654
+ 被外层超时 SIGTERM,那一刻没有任何机会写盘;只在流程末尾 save_plan 的话,
1655
+ taskId 永远存不下来,"续跑"就无从谈起。
1656
+ """
1657
+ if not job_id:
1658
+ return
1659
+ try:
1660
+ import render_job_client # type: ignore
1661
+ render_job_client.save_plan(job_id, json.dumps(render_plan, ensure_ascii=False), private_token)
1662
+ LogPrint(f" 💾 RenderPlan checkpoint saved (jobId={job_id})", file=sys.stderr)
1663
+ except Exception as exc: # noqa: BLE001 — 存档失败不该让渲染本身失败
1664
+ LogPrint(f" ⚠️ RenderPlan checkpoint failed (jobId={job_id}): {exc}", file=sys.stderr)
1665
+
1666
+
1650
1667
  def render_with_remote_api(
1651
1668
  render_plan: dict,
1652
1669
  output_path: str,
@@ -1656,10 +1673,14 @@ def render_with_remote_api(
1656
1673
  poll_interval: float = 5.0,
1657
1674
  upload_title: Optional[str] = None,
1658
1675
  conversation_id: Optional[str] = None,
1676
+ job_id: Optional[int] = None,
1659
1677
  ) -> bool:
1660
1678
  """Submit render to ab-api /tool/renderVideo and poll until completion.
1661
1679
 
1662
1680
  On success, writes upload.fileUrl into render_plan and skips local MP4 download.
1681
+
1682
+ job_id 用于中途存档(见 _persist_plan_checkpoint):有它才能在被强杀后续跑,
1683
+ 没有则退化成旧行为(一次性,杀了就得重渲)。
1663
1684
  """
1664
1685
  import remote_renderer_client
1665
1686
 
@@ -1706,6 +1727,13 @@ def render_with_remote_api(
1706
1727
  # 内置模板即使带 sourceOssKey(builtin-template-registry 回填,仅服务
1707
1728
  # derive_template 派生),渲染仍走 ab-render 预构建的 /render,绝不走 /renderDraft,
1708
1729
  # 否则会用 OSS 上的旧快照 + DraftMainVideo 复刻渲染,与生产 MainVideo 漂移。
1730
+ # 下面这套 payload 构造在"续跑既有任务"时其实用不上(见后面的 resuming 分支),
1731
+ # 所以它的失败不该让续跑也跟着失败 —— 任务还在后端好好渲着。
1732
+ pending_resume = bool(str(render_plan.get("remoteTaskId") or "").strip())
1733
+ # 先给默认值:presign 失败时下面的分支不会走到,续跑分支仍要读 status_path。
1734
+ render_path = "/render"
1735
+ status_path = "/renderStatus"
1736
+
1709
1737
  source_oss_key = None
1710
1738
  template_id = render_plan.get("templateId", "")
1711
1739
  if template_id:
@@ -1719,20 +1747,27 @@ def render_with_remote_api(
1719
1747
 
1720
1748
  if source_oss_key:
1721
1749
  import render_job_client # type: ignore
1750
+ tarball_url = None
1722
1751
  try:
1723
1752
  src = render_job_client.presign_template_source(template_id, private_token)
1724
1753
  tarball_url = src.get("tarballUrl")
1725
1754
  if not tarball_url:
1726
1755
  raise RuntimeError(f"presignSource 未返回 tarballUrl: {src}")
1727
1756
  except Exception as exc:
1728
- LogPrint(f"❌ presignSource failed for private template {template_id}: {exc}", file=sys.stderr)
1729
- render_plan["status"] = "failed"
1730
- render_plan["errors"].append({
1731
- "phase": "render",
1732
- "message": f"presignSource failed: {exc}",
1733
- "timestamp": now_iso(),
1734
- })
1735
- return False
1757
+ if pending_resume:
1758
+ LogPrint(
1759
+ f"⚠️ presignSource failed ({exc}) — 但有待续跑的任务,忽略继续",
1760
+ file=sys.stderr,
1761
+ )
1762
+ else:
1763
+ LogPrint(f"❌ presignSource failed for private template {template_id}: {exc}", file=sys.stderr)
1764
+ render_plan["status"] = "failed"
1765
+ render_plan["errors"].append({
1766
+ "phase": "render",
1767
+ "message": f"presignSource failed: {exc}",
1768
+ "timestamp": now_iso(),
1769
+ })
1770
+ return False
1736
1771
  payload = {
1737
1772
  "tarballUrl": tarball_url,
1738
1773
  "inputProps": input_props,
@@ -1759,8 +1794,10 @@ def render_with_remote_api(
1759
1794
  status_path = "/renderStatus"
1760
1795
 
1761
1796
  total_frames = config.get("totalFrames", 0)
1762
- LogPrint(f"🎬 Submitting remote render task...", file=sys.stderr)
1763
- if source_oss_key:
1797
+ if pending_resume:
1798
+ # 续跑:下面那堆"正在提交"的日志会误导人,跳过
1799
+ LogPrint(f"🎬 Remote render task already exists, will resume polling", file=sys.stderr)
1800
+ elif source_oss_key:
1764
1801
  LogPrint(f" Private template (OSS dynamic bundle): {template_id}", file=sys.stderr)
1765
1802
  else:
1766
1803
  LogPrint(f" Composition: {composition_id}", file=sys.stderr)
@@ -1770,28 +1807,54 @@ def render_with_remote_api(
1770
1807
  LogPrint(f" total frames: {total_frames}", file=sys.stderr)
1771
1808
  sys.stderr.flush()
1772
1809
 
1773
- try:
1774
- task_id = remote_renderer_client.start_render(payload, private_token=private_token, conversation_id=conversation_id, path=render_path)
1775
- except Exception as exc:
1776
- LogPrint(f"❌ remote render submission failed: {exc}", file=sys.stderr)
1777
- render_plan["status"] = "failed"
1778
- render_plan["errors"].append({
1810
+ # ─── 续跑既有任务 ────────────────────────────────────────────────────
1811
+ # 上一次调用可能是被外层超时 SIGTERM 掉的(skill 执行有上限),而远端任务还在
1812
+ # ab-render 上跑着。这时重新提交 = 白烧一遍渲染、并且大概率再次撞上同一个上限。
1813
+ # 所以先看 plan 里有没有上次留下的 taskId:有就直接续着轮询。
1814
+ #
1815
+ # 只在"任务不可用"(已失败 / 查不到)时才退回重新提交 —— 轮询超时不算不可用,
1816
+ # 那条路走 RemoteRenderTimeout,保留 taskId 交给下一次调用。
1817
+ task_id = str(render_plan.get("remoteTaskId") or "").strip()
1818
+ if task_id:
1819
+ # 提交时用的状态端点存在 plan 里(内置模板 /renderStatus,私有模板
1820
+ # /renderDraftStatus)。用存的那个,别依赖这次重新推导的结果。
1821
+ status_path = render_plan.get("remoteStatusPath") or status_path
1822
+ LogPrint(f"🔄 resuming existing remote task: taskId={task_id}", file=sys.stderr)
1823
+ render_plan["logs"].append({
1779
1824
  "phase": "render",
1780
- "message": f"remote start_render failed: {exc}",
1825
+ "message": f"Resuming remote task: {task_id}",
1781
1826
  "timestamp": now_iso(),
1782
1827
  })
1783
- return False
1828
+ else:
1829
+ try:
1830
+ task_id = remote_renderer_client.start_render(payload, private_token=private_token, conversation_id=conversation_id, path=render_path)
1831
+ except Exception as exc:
1832
+ LogPrint(f"❌ remote render submission failed: {exc}", file=sys.stderr)
1833
+ render_plan["status"] = "failed"
1834
+ render_plan["errors"].append({
1835
+ "phase": "render",
1836
+ "message": f"remote start_render failed: {exc}",
1837
+ "timestamp": now_iso(),
1838
+ })
1839
+ return False
1784
1840
 
1785
- LogPrint(f" ✅ task submitted, taskId={task_id}", file=sys.stderr)
1786
- render_plan["remoteTaskId"] = task_id
1787
- render_plan["logs"].append({
1788
- "phase": "render",
1789
- "message": f"Remote task submitted: {task_id}",
1790
- "timestamp": now_iso(),
1791
- })
1841
+ LogPrint(f" ✅ task submitted, taskId={task_id}", file=sys.stderr)
1842
+ render_plan["remoteTaskId"] = task_id
1843
+ render_plan["remoteStatusPath"] = status_path
1844
+ render_plan["remoteStartedAt"] = time.time()
1845
+ render_plan["logs"].append({
1846
+ "phase": "render",
1847
+ "message": f"Remote task submitted: {task_id}",
1848
+ "timestamp": now_iso(),
1849
+ })
1850
+ # **立刻落库**,不等渲染结束。被 SIGTERM 的那一刻脚本没有任何机会写盘,
1851
+ # 所以 taskId 必须在这里就进 DB,否则下一次调用无从续起(这正是旧代码
1852
+ # 只写内存、`remoteTaskId` 全程没人读的后果)。
1853
+ _persist_plan_checkpoint(render_plan, job_id, private_token)
1792
1854
 
1793
1855
  last_progress = -1.0
1794
- render_start_time = time.time()
1856
+ # 续跑时用提交时刻算 elapsed,否则 ETA 会按"刚开始"估,一上来就偏小。
1857
+ render_start_time = float(render_plan.get("remoteStartedAt") or time.time())
1795
1858
 
1796
1859
  def _on_progress(status_data: dict):
1797
1860
  nonlocal last_progress
@@ -1834,9 +1897,32 @@ def render_with_remote_api(
1834
1897
  adaptive_interval=True,
1835
1898
  status_path=status_path,
1836
1899
  )
1900
+ except remote_renderer_client.RemoteRenderTimeout as exc:
1901
+ # 等不动了,但任务**没失败**。保留 taskId,让下一次调用接着轮询;
1902
+ # 状态不写 failed(写了模型会当成"渲染失败"而重新走一遍完整流程)。
1903
+ LogPrint(f"⏳ {exc}", file=sys.stderr)
1904
+ render_plan["status"] = "rendering"
1905
+ _persist_plan_checkpoint(render_plan, job_id, private_token)
1906
+ render_plan["logs"].append({
1907
+ "phase": "render",
1908
+ "message": f"poll timeout, task still running: {task_id}",
1909
+ "timestamp": now_iso(),
1910
+ })
1911
+ print(
1912
+ f"\n⏳ 远端渲染仍在进行(taskId={task_id})。"
1913
+ f"用同一个 job_id 再调一次 render_video 即可续上,不会重新渲染。",
1914
+ flush=True,
1915
+ )
1916
+ return False
1837
1917
  except Exception as exc:
1838
1918
  LogPrint(f"❌ remote render polling failed: {exc}", file=sys.stderr)
1839
1919
  render_plan["status"] = "failed"
1920
+ # 任务确实不可用了(失败 / 查不到)。清掉 taskId,否则下一次调用会一直
1921
+ # 去续一个永远好不了的任务,再也提交不了新的。
1922
+ render_plan.pop("remoteTaskId", None)
1923
+ render_plan.pop("remoteStatusPath", None)
1924
+ render_plan.pop("remoteStartedAt", None)
1925
+ _persist_plan_checkpoint(render_plan, job_id, private_token)
1840
1926
  render_plan["errors"].append({
1841
1927
  "phase": "render",
1842
1928
  "message": f"remote poll_render failed: {exc}",
@@ -2030,8 +2116,11 @@ Examples:
2030
2116
  parser.add_argument(
2031
2117
  "--remote-poll-timeout",
2032
2118
  type=float,
2033
- default=float(os.environ.get("REMOTION_REMOTE_POLL_TIMEOUT", "1800")),
2034
- help="Remote-render polling timeout (seconds, default 1800)",
2119
+ default=float(os.environ.get("REMOTION_REMOTE_POLL_TIMEOUT", "2700")),
2120
+ # 实测最长的一支约 30 分钟,加上 ab-render 排队要留余量,所以是 45 分钟。
2121
+ # 这个值必须小于 ab-agent 侧 render_video 的 skill 超时(默认 50 分钟),
2122
+ # 否则外层先 SIGTERM,这里这套"超时但保留 taskId"的续跑逻辑根本轮不到执行。
2123
+ help="Remote-render polling timeout (seconds, default 2700)",
2035
2124
  )
2036
2125
  parser.add_argument(
2037
2126
  "--remote-poll-interval",
@@ -2405,6 +2494,7 @@ Examples:
2405
2494
  poll_interval=args.remote_poll_interval,
2406
2495
  upload_title=upload_title_hint,
2407
2496
  conversation_id=conversation_id,
2497
+ job_id=args.job_id if args.save_job else None,
2408
2498
  )
2409
2499
  else:
2410
2500
  LogPrint(f"💻 Using local render mode", file=sys.stderr)
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V20",
5
+ "version": "V21",
6
6
  "skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
7
7
  }
@@ -0,0 +1,32 @@
1
+ """Portable stdlib-only context and opt-in stderr diagnostics; never writes stdout."""
2
+ import json
3
+ import os
4
+ import re
5
+ import secrets
6
+ import sys
7
+ import time
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+
11
+ _started = time.monotonic()
12
+
13
+ def trace_headers():
14
+ match = re.fullmatch(r'00-([a-f0-9]{32})-([a-f0-9]{16})-[a-f0-9]{2}', os.getenv('AB_TRACEPARENT', ''))
15
+ tid = match[1] if match and match[1] != '0'*32 and match[2] != '0'*16 else secrets.token_hex(16)
16
+ parent = f'00-{tid}-{secrets.token_hex(8)}-01'
17
+ os.environ['AB_TRACEPARENT'] = parent
18
+ os.environ.setdefault('AB_OPERATION_ID', str(uuid.uuid4()))
19
+ return {'traceparent': parent}
20
+
21
+ def diagnostic(exit_code, kind='none', service='remixmate-studio-cli'):
22
+ if os.getenv('AB_DIAGNOSTICS') != '1':
23
+ return
24
+ trace_headers()
25
+ outcome = 'success' if exit_code == 0 else 'failure' if exit_code == 2 else 'rejected'
26
+ record = dict(schema_version=1, timestamp=datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
27
+ level='error' if outcome == 'failure' else 'info', service=service, environment=os.getenv('APP_ENV', 'dev'),
28
+ event='cli.execution.completed', message='CLI execution completed', outcome=outcome,
29
+ duration_ms=(time.monotonic()-_started)*1000, trace_id=os.environ['AB_TRACEPARENT'].split('-')[1],
30
+ operation_id=os.environ['AB_OPERATION_ID'], attributes={'exit_code': exit_code, 'error_kind': kind if re.fullmatch('[a-z_]{1,80}', str(kind)) else 'unknown'})
31
+ sys.stderr.write('__diagnostic_v1__ ' + json.dumps(record, ensure_ascii=False) + '\n')
32
+ sys.stderr.flush()
@@ -11,6 +11,7 @@ gen_jianying_draft.py 使用。
11
11
  两个变量语义相同,保留回退是为兼容只配了其中一个的部署环境。
12
12
  """
13
13
 
14
+ from log_diagnostics import trace_headers
14
15
  import json
15
16
  import os
16
17
  import sys
@@ -34,6 +35,7 @@ def _make_headers(priv_token: str) -> dict:
34
35
  agent_name = os.environ.get("AGENT_NAME", "")
35
36
  if agent_name:
36
37
  headers["x-invoke-agent"] = agent_name
38
+ headers.update(trace_headers())
37
39
  return headers
38
40
 
39
41
 
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V13",
5
+ "version": "V14",
6
6
  "skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
7
7
  }