@xdxer/dingtalk-agent 0.1.5-beta.7 → 0.1.5-beta.9

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.en.md +1 -0
  3. package/README.md +1 -0
  4. package/dist/bin/dingtalk-agent.js +118 -15
  5. package/dist/bin/dingtalk-agent.js.map +1 -1
  6. package/dist/src/agent-audit.js +108 -9
  7. package/dist/src/agent-audit.js.map +1 -1
  8. package/dist/src/development-workspace.js +37 -1
  9. package/dist/src/development-workspace.js.map +1 -1
  10. package/dist/src/dws.js +9 -0
  11. package/dist/src/dws.js.map +1 -1
  12. package/dist/src/multica-deploy.js +347 -105
  13. package/dist/src/multica-deploy.js.map +1 -1
  14. package/dist/src/multica-provider.js +151 -27
  15. package/dist/src/multica-provider.js.map +1 -1
  16. package/dist/src/multica-runtime-vocabulary.js +110 -0
  17. package/dist/src/multica-runtime-vocabulary.js.map +1 -0
  18. package/dist/src/promotion.js +2 -1
  19. package/dist/src/promotion.js.map +1 -1
  20. package/dist/src/schedule-plan.js +192 -24
  21. package/dist/src/schedule-plan.js.map +1 -1
  22. package/dist/src/skill-manager.js +76 -9
  23. package/dist/src/skill-manager.js.map +1 -1
  24. package/docs/AGENT-IN-PRODUCTION.md +255 -0
  25. package/docs/ARCHITECTURE.md +5 -1
  26. package/docs/PLATFORM-GUARDRAILS.md +188 -0
  27. package/docs/schemas/multica-deployment-plan.schema.json +3 -1
  28. package/docs/schemas/multica-deployment-receipt.schema.json +15 -1
  29. package/docs/schemas/multica-deployment-status.schema.json +6 -2
  30. package/docs/schemas/multica-workspace-inspection.schema.json +16 -0
  31. package/docs/schemas/multica-workspace-status.schema.json +2 -0
  32. package/docs/schemas/workspace-scaffold.schema.json +1 -0
  33. package/lab/project-workspace/fake-multica-provider.mjs +97 -7
  34. package/lab/project-workspace/multica-deploy.fixture.json +2 -2
  35. package/lab/project-workspace/multica-readonly.fixture.json +2 -2
  36. package/lab/project-workspace/project.fixture.json +1 -1
  37. package/lab/robot-eval/suite.json +1 -1
  38. package/package.json +5 -2
  39. package/skills/core/dta-agent-compose/SKILL.md +1 -1
  40. package/skills/core/dta-agent-compose/references/drive-and-schedules.md +22 -2
  41. package/skills/core/dta-basic-behavior/SKILL.md +1 -1
  42. package/skills/core/dta-basic-behavior/references/memory-and-evolution.md +1 -1
  43. package/skills/core/dta-people-group-memory/SKILL.md +4 -4
  44. package/skills/core/dta-people-group-memory/references/model.md +1 -1
  45. package/skills/platforms/multica-dingtalk/PLATFORM.md +2 -2
  46. package/skills/platforms/multica-dingtalk/dta-deploy-multica/SKILL.md +6 -5
  47. package/skills/platforms/multica-dingtalk/dta-deploy-multica/references/multica-deployment-contract.md +14 -4
  48. package/skills/platforms/multica-dingtalk/dta-ops-multica/SKILL.md +33 -7
  49. package/skills/platforms/multica-dingtalk/dta-ops-multica/scripts/multica_ext.py +118 -12
  50. package/dist/src/map.js +0 -157
  51. package/dist/src/map.js.map +0 -1
@@ -5,7 +5,8 @@ import { tmpdir } from 'node:os';
5
5
  import { basename, dirname, join, relative, resolve } from 'node:path';
6
6
  import { WORKSPACE_STATE_ROOT, inspectAgentProject, projectSkillRef, showDevelopmentWorkspace, } from './development-workspace.js';
7
7
  import { digest, stableStringify } from './events.js';
8
- import { MULTICA_EVIDENCE_ROOT, inspectMulticaWorkspace, planMulticaWorkspace, statusMulticaWorkspace, } from './multica-provider.js';
8
+ import { MULTICA_EVIDENCE_ROOT, emitProviderDiagnostic, inspectMulticaWorkspace, planMulticaWorkspace, sanitizeProviderEnv, statusMulticaWorkspace, } from './multica-provider.js';
9
+ import { allRuntimeVocabularies, commandInvokesDws, forbiddenSmokeTool, skillsLoadedInContent, vocabulariesForRuntimeProvider, } from './multica-runtime-vocabulary.js';
9
10
  export const MULTICA_DEPLOYMENT_SCHEMA = 'dingtalk-agent/multica-deployment-receipt@1';
10
11
  const MAX_DEPLOY_SKILLS = 32;
11
12
  const MAX_SKILL_SUPPORTING_FILES = 128;
@@ -13,10 +14,12 @@ const deploymentWriteBudgets = new WeakMap();
13
14
  class DeploymentFailure extends Error {
14
15
  category;
15
16
  ambiguous;
16
- constructor(category, ambiguous = false) {
17
+ detail;
18
+ constructor(category, ambiguous = false, detail) {
17
19
  super(category);
18
20
  this.category = category;
19
21
  this.ambiguous = ambiguous;
22
+ this.detail = detail;
20
23
  }
21
24
  }
22
25
  export function runMulticaWorkspaceIssue(start, name, prompt, options = {}) {
@@ -80,6 +83,7 @@ export function runMulticaWorkspaceIssue(start, name, prompt, options = {}) {
80
83
  if (!inspection.passed || inspection.resources.workspace?.id !== plan.workspaceId ||
81
84
  inspection.resources.runtime?.id !== plan.runtimeId ||
82
85
  inspection.resources.agent?.id !== plan.agentId ||
86
+ inspection.diff.includes('agent.runtime-drift') ||
83
87
  stableStringify(assignedSkills) !== stableStringify(expectedSkills)) {
84
88
  throw new Error('Multica workspace run scope 或 Skill assignment 与 Project 不一致;未创建 Issue');
85
89
  }
@@ -92,6 +96,7 @@ export function runMulticaWorkspaceIssue(start, name, prompt, options = {}) {
92
96
  const issueId = resourceId(issue.id, 'workspace run issue.id');
93
97
  return readbackWorkspaceRun({
94
98
  root: info.root, name, plan, issueId, runId, command, env, timeoutMs, calls, out: options.out,
99
+ runtimeProvider: inspection.resources.runtime?.provider || '',
95
100
  });
96
101
  }
97
102
  /**
@@ -131,13 +136,43 @@ function readbackWorkspaceRun(ctx) {
131
136
  }
132
137
  const toolUses = messages.filter((item) => optionalString(item.type) === 'tool_use');
133
138
  const toolNames = toolUses.map((item) => optionalString(item.tool)).filter(Boolean);
134
- const loadedSkills = toolUses.filter((item) => optionalString(item.tool) === 'skill')
135
- .map((item) => optionalString(smokeToolInput(item).name)).filter(Boolean);
139
+ // Skill 装载与文件写入按 runtime 词汇解读(并集),并叠加 tool_result
140
+ // `**Skill loaded**` 正向证据 —— 与 smoke 判定同一套词汇,不再假定工具名叫 skill。
141
+ const vocabularies = ctx.runtimeProvider
142
+ ? vocabulariesForRuntimeProvider(ctx.runtimeProvider) : [];
143
+ const active = vocabularies.length
144
+ ? vocabularies : allRuntimeVocabularies();
145
+ const loadedSet = new Set();
146
+ const runBannerTools = new Set(active.flatMap((item) => [...item.skillResultTools]));
147
+ for (const item of messages) {
148
+ const type = optionalString(item.type);
149
+ const tool = optionalString(item.tool);
150
+ if (type === 'tool_use') {
151
+ const message = { tool, input: smokeToolInput(item) };
152
+ for (const vocabulary of active) {
153
+ const name = vocabulary.skillLoadFromToolUse(message);
154
+ if (name)
155
+ loadedSet.add(name);
156
+ }
157
+ }
158
+ else if (type === 'tool_result' && runBannerTools.has(tool)) {
159
+ for (const name of skillsLoadedInContent(optionalString(item.content)))
160
+ loadedSet.add(name);
161
+ }
162
+ }
163
+ const loadedSkills = [...loadedSet].sort();
136
164
  const texts = messages.filter((item) => optionalString(item.type) === 'text')
137
165
  .map((item) => optionalString(item.content)).filter(Boolean);
138
- const replyWrites = toolUses.filter((item) => optionalString(item.tool) === 'write')
139
- .map((item) => smokeToolInput(item)).filter((input) => optionalString(input.filePath).endsWith('/reply.md'))
140
- .map((input) => optionalString(input.content)).filter(Boolean);
166
+ const replyWrites = toolUses.flatMap((item) => {
167
+ const message = {
168
+ tool: optionalString(item.tool), input: smokeToolInput(item),
169
+ };
170
+ return active.map((vocabulary) => vocabulary.writeEvidence(message))
171
+ .filter((write) => Boolean(write))
172
+ // 裸文件名与 ./、绝对路径等价(平台常见写法,与 smoke 白名单同一课)。
173
+ .filter((write) => write.path === 'reply.md' || write.path.endsWith('/reply.md'))
174
+ .map((write) => write.content);
175
+ }).filter(Boolean);
141
176
  const answer = safeRemoteOutput(replyWrites.at(-1) || texts.at(-1) || '', 64 * 1024);
142
177
  const issueReadback = jsonObject(runProvider(command, scopedArgs({ profile: plan.profile, expected: { workspaceId: plan.workspaceId } }, [
143
178
  'issue', 'get', issueId, '--output', 'json',
@@ -265,6 +300,7 @@ export function statusMulticaWorkspaceRun(start, name, options = {}) {
265
300
  }
266
301
  export function planMulticaDeployment(start, name, options = {}) {
267
302
  const action = options.action || 'apply';
303
+ const rebindRuntime = Boolean(options.rebindRuntime) && action === 'apply';
268
304
  const env = options.env || process.env;
269
305
  const cliVersion = options.cliVersion || '';
270
306
  const info = inspectAgentProject(start, cliVersion);
@@ -273,12 +309,21 @@ export function planMulticaDeployment(start, name, options = {}) {
273
309
  const readPlan = planMulticaWorkspace(info.root, name, { env, cliVersion });
274
310
  const blocking = [...readPlan.blocking];
275
311
  let inspectionId = '';
312
+ let previousRuntimeId = '';
276
313
  try {
277
314
  const status = statusMulticaWorkspace(info.root, name, cliVersion);
278
315
  inspectionId = status.inspectionId;
279
316
  if (!status.readyForApply || !status.evidenceIntegrity || !status.stateMatched) {
280
317
  blocking.push(...status.failures.map((item) => `inspection.${item}`));
281
318
  }
319
+ // Runtime 归属漂移只挡「普通 apply」:没有显式 --rebind-runtime 时不允许悄悄把
320
+ // Agent 搬到新 runtime。retire 是收敛动作,不受漂移阻挡(否则 deploy 与 retire
321
+ // 会被同一把锁一起锁死)。
322
+ if (status.diff.includes('agent.runtime-drift')) {
323
+ previousRuntimeId = status.observedAgentRuntimeId || '';
324
+ if (action === 'apply' && !rebindRuntime)
325
+ blocking.push('agent.runtime-drift');
326
+ }
282
327
  }
283
328
  catch {
284
329
  blocking.push('inspection.invalid');
@@ -287,26 +332,30 @@ export function planMulticaDeployment(start, name, options = {}) {
287
332
  blocking.push('inspection.missing');
288
333
  if (action === 'retire' && !readPlan.expected.agentId)
289
334
  blocking.push('agent-id.missing');
335
+ if (rebindRuntime && !readPlan.expected.agentId)
336
+ blocking.push('rebind.agent-id-missing');
290
337
  const expectedSkills = source.skills.map((skill) => ({
291
338
  name: skill.name, hash: skill.hash, files: skill.files.length + 1,
292
339
  }));
293
340
  const operations = action === 'retire'
294
341
  ? ['scope.reinspect', 'agent.archive', 'agent.readback', 'receipt.persist']
295
342
  : [
296
- 'scope.reinspect', 'skills.readback', 'skills.reconcile', 'agent.reconcile',
343
+ 'scope.reinspect', 'skills.readback', 'skills.reconcile',
344
+ ...(rebindRuntime ? ['agent.rebind-runtime'] : []), 'agent.reconcile',
297
345
  'skills.assign-exact', 'provider.readback', 'load-smoke.create',
298
346
  'load-smoke.recover-if-unstarted', 'load-smoke.readback', 'receipt.persist',
299
347
  ];
300
348
  const writeBudget = action === 'retire'
301
349
  ? { forward: 1, rollback: 0 }
302
350
  : {
303
- forward: source.skills.length * (1 + MAX_SKILL_SUPPORTING_FILES * 2) + 5,
351
+ // +3 on top of the base 5: bounded skills-not-visible smoke retries (cold sandbox).
352
+ forward: source.skills.length * (1 + MAX_SKILL_SUPPORTING_FILES * 2) + 8,
304
353
  rollback: source.skills.length * (1 + MAX_SKILL_SUPPORTING_FILES * 2) + 3,
305
354
  };
306
355
  const planId = `multica_deploy_plan_${digest(stableStringify({
307
356
  action, workspace: name, profile: readPlan.profile, inspectionId,
308
357
  desiredHash: workspace.desiredHash, deploymentHash: source.deploymentHash,
309
- expected: readPlan.expected, operations, writeBudget,
358
+ expected: readPlan.expected, operations, writeBudget, rebindRuntime, previousRuntimeId,
310
359
  blocking: [...new Set(blocking)].sort(),
311
360
  })).slice(0, 24)}`;
312
361
  return {
@@ -327,7 +376,9 @@ export function planMulticaDeployment(start, name, options = {}) {
327
376
  skills: expectedSkills,
328
377
  definitionHash: source.definitionHash,
329
378
  instructionsHash: source.instructionsHash,
379
+ ...(rebindRuntime ? { previousRuntimeId } : {}),
330
380
  },
381
+ rebindRuntime,
331
382
  operations,
332
383
  writeBudget,
333
384
  blocking: [...new Set(blocking)].sort(),
@@ -348,9 +399,12 @@ export function applyMulticaDeployment(start, name, options = {}, action = 'appl
348
399
  throw new Error('dta deploy 必须传 dry-run 返回的 --plan-id');
349
400
  const env = options.env || process.env;
350
401
  const cliVersion = options.cliVersion || '';
351
- const plan = planMulticaDeployment(start, name, { action, env, cliVersion });
402
+ const rebindRuntime = Boolean(options.rebindRuntime) && action === 'apply';
403
+ const plan = planMulticaDeployment(start, name, { action, env, cliVersion, rebindRuntime });
352
404
  if (options.planId !== plan.planId) {
353
- throw new Error(`deploy plan 已失效;期望当前 planId ${plan.planId}`);
405
+ throw new Error(`deploy plan 已失效;期望当前 planId ${plan.planId}。` +
406
+ 'plan 对中间状态敏感:dry-run 之后、apply 之前不要插入 workspace inspect 等操作;' +
407
+ '重跑 --dry-run 取新 planId 后立即 apply');
354
408
  }
355
409
  if (plan.blocking.length)
356
410
  throw new Error(`deploy 前置条件不完整: ${plan.blocking.join(', ')}`);
@@ -368,7 +422,12 @@ export function applyMulticaDeployment(start, name, options = {}, action = 'appl
368
422
  preflight.profile !== plan.profile ||
369
423
  preflight.expected.workspaceId !== plan.expected.workspaceId ||
370
424
  preflight.expected.runtimeId !== plan.expected.runtimeId) {
371
- throw new Error('deploy scope/readback 与冻结 plan 不一致;未执行任何远端写入');
425
+ throw new Error('deploy scope/readback 与冻结 plan 不一致;未执行任何远端写入' +
426
+ '(dry-run 与 apply 之间若插入过 inspect 或远端有变化,重跑 --dry-run 后立即 apply)');
427
+ }
428
+ if (action === 'apply' && !rebindRuntime && preflight.diff.includes('agent.runtime-drift')) {
429
+ throw new Error('deploy 前置条件不完整: agent.runtime-drift' +
430
+ '(Agent 当前绑定在其他 runtime;显式迁移用 --rebind-runtime,退役用 --retire)');
372
431
  }
373
432
  appendInspectionCalls(preflight, 'preflight', calls);
374
433
  const operationId = `multica_operation_${Date.now()}_${randomUUID().slice(0, 12)}`;
@@ -401,7 +460,7 @@ export function applyMulticaDeployment(start, name, options = {}, action = 'appl
401
460
  operation.calls = calls;
402
461
  operation.failures = [failure.category, ...rollbackReport.failures];
403
462
  operation.updatedAt = new Date().toISOString();
404
- receipt = failureReceipt(info.root, workspace, source, plan, preflight, operation, calls, rollbackReport, status, failure.category);
463
+ receipt = failureReceipt(info.root, workspace, source, plan, preflight, operation, calls, rollbackReport, status, failure.category, failure.detail);
405
464
  }
406
465
  finally {
407
466
  rmSync(temp, { recursive: true, force: true });
@@ -429,6 +488,7 @@ function executeApply(root, workspace, source, plan, preflight, operation, comma
429
488
  calls: [...calls],
430
489
  agent: { ...previous.agent, action: 'noop' },
431
490
  skills: previous.skills.map((skill) => ({ ...skill, action: 'noop' })),
491
+ failureDetails: Array.isArray(previous.failureDetails) ? previous.failureDetails : [],
432
492
  evidencePath: receiptRelativePath(workspace.name, receiptId),
433
493
  remoteWrite: false,
434
494
  sideEffect: 'multica-read',
@@ -477,9 +537,12 @@ function executeApply(root, workspace, source, plan, preflight, operation, comma
477
537
  throw new DeploymentFailure('readback.deployment-mismatch');
478
538
  }
479
539
  const observedHash = deploymentObservedHash(finalSnapshot, readback.inspectionId);
480
- const smoke = createAndReadSmoke(root, plan, source, agentId, command, env, timeoutMs, smokeTimeoutMs, noWait, calls);
540
+ const smoke = createAndReadSmoke(root, plan, source, agentId, readback.resources.runtime?.provider || '', command, env, timeoutMs, smokeTimeoutMs, noWait, calls);
541
+ // skills-not-visible 是冷沙箱竞态(推送的 Skill 尚未物化),不是真实部署失败:落
542
+ // verifying 而非 failed,避免把一次正常部署误判成失败、把人推去改本体换模板。
481
543
  const status = smoke.status === 'passed'
482
- ? 'ready' : smoke.status === 'pending' ? 'verifying' : 'failed';
544
+ ? 'ready' : smoke.status === 'pending' ? 'verifying'
545
+ : smoke.classification === 'skills-not-visible' ? 'verifying' : 'failed';
483
546
  const receiptId = `multica_receipt_${digest(stableStringify({
484
547
  operationId: operation.operationId, planId: plan.planId, observedHash,
485
548
  smoke: { markerHash: smoke.markerHash, issueId: smoke.issueId, taskId: smoke.taskId },
@@ -516,6 +579,13 @@ function executeApply(root, workspace, source, plan, preflight, operation, comma
516
579
  smoke,
517
580
  rollback: emptyRollback(false),
518
581
  failures: [...smoke.failures],
582
+ failureDetails: [...smoke.failureDetails],
583
+ ...(plan.rebindRuntime && plan.expected.previousRuntimeId ? {
584
+ rebind: {
585
+ fromRuntimeId: plan.expected.previousRuntimeId,
586
+ toRuntimeId: plan.expected.runtimeId,
587
+ },
588
+ } : {}),
519
589
  evidencePath: receiptRelativePath(workspace.name, receiptId),
520
590
  remoteRead: true,
521
591
  remoteWrite: calls.some((call) => call.kind === 'write'),
@@ -570,6 +640,7 @@ function retireDeployment(root, workspace, source, plan, preflight, operation, c
570
640
  smoke: emptySmoke('not-run', source.skills.map((item) => item.name)),
571
641
  rollback: emptyRollback(false),
572
642
  failures: [],
643
+ failureDetails: [],
573
644
  evidencePath: receiptRelativePath(workspace.name, receiptId),
574
645
  remoteRead: true,
575
646
  remoteWrite: calls.some((call) => call.kind === 'write'),
@@ -784,7 +855,7 @@ function upsertSkillFile(root, plan, skillId, path, contentFile, command, env, t
784
855
  '--content-file', contentFile, '--output', 'json',
785
856
  ]), `skill.file.upsert:${path}`, 'write', root, env, timeoutMs, calls);
786
857
  }
787
- function createAndReadSmoke(root, plan, source, agentId, command, env, timeoutMs, smokeTimeoutMs, noWait, calls) {
858
+ function createAndReadSmoke(root, plan, source, agentId, runtimeProvider, command, env, timeoutMs, smokeTimeoutMs, noWait, calls) {
788
859
  const marker = `DTA-MULTICA-LOAD-${randomUUID()}`;
789
860
  const requiredSkills = source.skills.map((item) => item.name).sort();
790
861
  const expectedResponse = JSON.stringify({
@@ -796,14 +867,15 @@ function createAndReadSmoke(root, plan, source, agentId, command, env, timeoutMs
796
867
  'DTA_MULTICA_LOAD_SMOKE@1', `marker=${marker}`,
797
868
  `required_skills=${JSON.stringify(requiredSkills)}`,
798
869
  'This is a deployment control-plane verification task.',
799
- 'Use the Host native Skill tool exactly once for every required skill above, in the listed order.',
870
+ 'Load every required skill above through the Host skill mechanism, in the listed order.',
800
871
  'Do not call DWS, network, or business tools. Only the Host Issue control-plane calls needed to read this Issue and persist the exact result are exempt.',
801
- `Return exactly this JSON and nothing else: ${expectedResponse}`,
872
+ `Post exactly this JSON as an Issue comment (and nothing else): ${expectedResponse}`,
802
873
  ].join('\n'),
803
874
  '--assignee-id', agentId, '--output', 'json',
804
875
  ]), 'smoke.issue.create', 'write', root, env, timeoutMs, calls).stdout, 'smoke issue');
805
876
  const issueId = resourceId(issue.id, 'smoke issue.id');
806
- let smoke = readSmoke(root, plan, marker, issueId, agentId, requiredSkills, command, env, timeoutMs, calls);
877
+ const read = () => readSmoke(root, plan, marker, issueId, agentId, requiredSkills, runtimeProvider, command, env, timeoutMs, calls);
878
+ let smoke = read();
807
879
  // Multica persists the Issue before attempting the initial agent enqueue. If
808
880
  // that enqueue loses a transient readiness race, the API still returns a
809
881
  // valid assigned todo Issue but issue runs remains empty forever. Recover
@@ -812,19 +884,65 @@ function createAndReadSmoke(root, plan, source, agentId, command, env, timeoutMs
812
884
  // duplicated. The mention route is used instead of issue rerun because it is
813
885
  // the platform's normal first-run trigger and does not require a prior task.
814
886
  if (smoke.status === 'pending' && !smoke.taskId) {
815
- runProvider(command, scopedArgs(plan, [
816
- 'issue', 'comment', 'add', issueId,
817
- '--content', `[@DTA load smoke](mention://agent/${agentId}) Execute the deployment verification exactly as specified in this Issue description.`,
818
- '--output', 'json',
819
- ]), 'smoke.issue.recover', 'write', root, env, timeoutMs, calls);
820
- smoke = readSmoke(root, plan, marker, issueId, agentId, requiredSkills, command, env, timeoutMs, calls);
887
+ try {
888
+ runProvider(command, scopedArgs(plan, [
889
+ 'issue', 'comment', 'add', issueId,
890
+ '--content', `[@DTA load smoke](mention://agent/${agentId}) Execute the deployment verification exactly as specified in this Issue description.`,
891
+ '--output', 'json',
892
+ ]), 'smoke.issue.recover', 'write', root, env, timeoutMs, calls);
893
+ smoke = read();
894
+ }
895
+ catch {
896
+ // 与重试 mention 同一不变量:部署本体已回读验证,一次评论写入的抖动只让 smoke
897
+ // 停在 pending(收敛为 verifying,由 --status 收口),不允许升级为回滚。
898
+ }
821
899
  }
822
900
  if (noWait)
823
901
  return smoke;
824
902
  const started = Date.now();
825
- while (smoke.status === 'pending' && Date.now() - started < smokeTimeoutMs) {
826
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2_000);
827
- smoke = readSmoke(root, plan, marker, issueId, agentId, requiredSkills, command, env, timeoutMs, calls);
903
+ const pollToTerminal = () => {
904
+ while (smoke.status === 'pending' && Date.now() - started < smokeTimeoutMs) {
905
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2_000);
906
+ smoke = read();
907
+ }
908
+ };
909
+ pollToTerminal();
910
+ // 冷沙箱竞态:推送的 Skill 要数分钟才对运行时可见,任务报 `Skill not found` 不是
911
+ // 真失败。在预算内用 mention 触发新 task(新沙箱有机会看到新 Skill),有限次重试。
912
+ // 默认 30s 预算下基本轮不到重试;--wait / --smoke-timeout 放宽后才生效。
913
+ const retryDelayMs = Math.min(20_000, Math.max(500, Math.floor(smokeTimeoutMs / 12)));
914
+ let retries = 0;
915
+ while (smoke.status === 'failed' && smoke.classification === 'skills-not-visible' &&
916
+ retries < 3 && Date.now() - started < smokeTimeoutMs) {
917
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs);
918
+ try {
919
+ runProvider(command, scopedArgs(plan, [
920
+ 'issue', 'comment', 'add', issueId,
921
+ '--content', `[@DTA load smoke](mention://agent/${agentId}) Skills were not yet visible in the sandbox; re-run the deployment verification exactly as specified in this Issue description.`,
922
+ '--output', 'json',
923
+ ]), 'smoke.issue.retry', 'write', root, env, timeoutMs, calls);
924
+ }
925
+ catch {
926
+ // 一次评论写入的抖动不允许升级为对已验证部署的回滚:失败已记入 calls,
927
+ // 停止重试并保持当前 smoke 判定(skills-not-visible → verifying)。
928
+ break;
929
+ }
930
+ retries += 1;
931
+ // mention 触发的新 run 在平台上是异步创建的:只有当最新 run 的 taskId 变化后
932
+ // 才重新判定;等不到新 run 就停手——绝不因为还读到旧终态 run 而重复 mention。
933
+ const staleTaskId = smoke.taskId;
934
+ let sawNewRun = false;
935
+ while (Date.now() - started < smokeTimeoutMs) {
936
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2_000);
937
+ smoke = read();
938
+ if (smoke.taskId && smoke.taskId !== staleTaskId) {
939
+ sawNewRun = true;
940
+ break;
941
+ }
942
+ }
943
+ if (!sawNewRun)
944
+ break;
945
+ pollToTerminal();
828
946
  }
829
947
  if (smoke.status === 'pending' && !smoke.taskDiagnostic) {
830
948
  const detail = smoke.taskId
@@ -834,55 +952,180 @@ function createAndReadSmoke(root, plan, source, agentId, command, env, timeoutMs
834
952
  }
835
953
  return smoke;
836
954
  }
837
- function readSmoke(root, plan, marker, issueId, agentId, requiredSkills, command, env, timeoutMs, calls) {
955
+ /**
956
+ * Judge a smoke run from positive evidence instead of a tool-name whitelist. The old judge
957
+ * required every tool_use to match hardcoded `skill`/`write`/`bash` shapes, so a healthy
958
+ * cloud runtime emitting `read_file`/`terminal` could never reach ready — and one extra
959
+ * harmless tool failed the whole trace. Now: each required skill needs load evidence
960
+ * (vocabulary tool_use or host-generated `**Skill loaded**` tool_result), the response is
961
+ * taken from the Issue comments themselves first (platform readback, vocabulary-independent),
962
+ * and only the explicit dws denylist is fatal. Unknown tool names are recorded, not judged.
963
+ */
964
+ function readSmoke(root, plan, marker, issueId, agentId, requiredSkills, runtimeProvider, command, env, timeoutMs, calls) {
965
+ const provider = runtimeProvider || '';
966
+ const registered = vocabulariesForRuntimeProvider(provider);
967
+ const active = registered.length
968
+ ? registered : allRuntimeVocabularies();
969
+ const vocabularyIds = active.map((item) => item.id);
970
+ const base = {
971
+ ...emptySmoke('pending', requiredSkills), marker, markerHash: sha256(marker), issueId,
972
+ runtimeProvider: provider, vocabularyIds,
973
+ };
838
974
  const runs = jsonArray(runProvider(command, scopedArgs(plan, ['issue', 'runs', issueId, '--full-id', '--output', 'json']), 'smoke.issue.runs', 'read', root, env, timeoutMs, calls).stdout, 'smoke runs');
839
975
  const candidates = runs.filter((item) => !optionalString(item.agent_id) ||
840
976
  optionalString(item.agent_id) === agentId);
841
977
  const run = candidates.sort((a, b) => optionalString(b.created_at).localeCompare(optionalString(a.created_at)))[0];
842
978
  if (!run)
843
- return {
844
- ...emptySmoke('pending', requiredSkills), marker, markerHash: sha256(marker), issueId,
845
- };
979
+ return base;
846
980
  const taskId = resourceId(run.id, 'smoke task.id');
847
981
  const taskStatus = optionalString(run.status);
848
982
  if (!['completed', 'failed', 'cancelled'].includes(taskStatus))
849
983
  return {
850
- ...emptySmoke('pending', requiredSkills), marker, markerHash: sha256(marker), issueId, taskId, taskStatus,
984
+ ...base, taskId, taskStatus,
851
985
  };
852
986
  const messages = jsonArray(runProvider(command, scopedArgs(plan, [
853
987
  'issue', 'run-messages', taskId, '--issue', issueId, '--output', 'json',
854
988
  ]), 'smoke.issue.messages', 'read', root, env, timeoutMs, calls).stdout, 'smoke messages');
855
989
  const toolUses = messages.filter((item) => optionalString(item.type) === 'tool_use');
856
- const skillToolUses = toolUses.filter((item) => optionalString(item.tool) === 'skill');
857
- const skillNames = skillToolUses.map((item) => smokeToolInput(item).name)
858
- .filter((item) => typeof item === 'string' && Boolean(item)).sort();
859
- const loadedSkills = requiredSkills.filter((name) => skillNames.includes(name)).sort();
990
+ const toolNames = [...new Set(toolUses.map((item) => optionalString(item.tool))
991
+ .filter(Boolean))].sort();
992
+ // Skill-load evidence: a native skill tool_use (the host mechanism itself), or the
993
+ // host-generated `**Skill loaded**` banner in a skill-capable tool's tool_result. Two
994
+ // things are deliberately NOT evidence: model text (a reply can claim anything), and
995
+ // load ATTEMPTS — a failed cold-sandbox load emits the same "Loading skill" tool_use
996
+ // before its error result, and a terminal/bash result echoes whatever the model printed.
997
+ const evidence = new Set();
998
+ const attempted = new Set();
999
+ const evidenceTools = new Set();
1000
+ const bannerTools = new Set(active.flatMap((item) => [...item.skillResultTools]));
1001
+ for (const item of messages) {
1002
+ const type = optionalString(item.type);
1003
+ const tool = optionalString(item.tool);
1004
+ if (type === 'tool_use') {
1005
+ const message = { tool, input: smokeToolInput(item) };
1006
+ for (const vocabulary of active) {
1007
+ const name = vocabulary.skillLoadFromToolUse(message);
1008
+ if (name) {
1009
+ evidence.add(name);
1010
+ evidenceTools.add(tool);
1011
+ }
1012
+ const attempt = vocabulary.skillAttemptFromToolUse(message);
1013
+ if (attempt) {
1014
+ attempted.add(attempt);
1015
+ evidenceTools.add(tool);
1016
+ }
1017
+ }
1018
+ }
1019
+ else if (type === 'tool_result' && bannerTools.has(tool)) {
1020
+ for (const name of skillsLoadedInContent(optionalString(item.content))) {
1021
+ evidence.add(name);
1022
+ evidenceTools.add(tool);
1023
+ }
1024
+ }
1025
+ }
1026
+ const loadedSkills = requiredSkills.filter((name) => evidence.has(name)).sort();
1027
+ const skillEvidencePassed = loadedSkills.length === requiredSkills.length;
1028
+ // dws is the DingTalk side-effect boundary: as a named tool or shelled out in a command.
1029
+ const commandTexts = toolUses.flatMap((item) => {
1030
+ const message = {
1031
+ tool: optionalString(item.tool), input: smokeToolInput(item),
1032
+ };
1033
+ return active.map((vocabulary) => vocabulary.commandText(message)).filter(Boolean);
1034
+ });
1035
+ const forbiddenTools = toolNames.filter((name) => forbiddenSmokeTool(name));
1036
+ const forbiddenCommands = commandTexts.some((text) => commandInvokesDws(text));
1037
+ const knownTools = new Set(active.flatMap((item) => [...item.tools]));
1038
+ const unrecognizedTools = toolNames.filter((name) => !knownTools.has(name) && !evidenceTools.has(name) && !forbiddenSmokeTool(name));
1039
+ // Response evidence, most stable source first: the Issue's own comments (platform
1040
+ // readback — no tool vocabulary involved), then trace texts, writes and shell heredocs.
1041
+ const matchesResponse = (value) => smokeResponseMatches(value, marker, requiredSkills);
1042
+ let commentContents = [];
1043
+ try {
1044
+ commentContents = jsonArray(runProvider(command, scopedArgs(plan, [
1045
+ 'issue', 'comment', 'list', issueId, '--recent', '20', '--output', 'json',
1046
+ ]), 'smoke.issue.comments', 'read', root, env, timeoutMs, calls).stdout, 'smoke comments').map((item) => optionalString(item.content)).filter(Boolean);
1047
+ }
1048
+ catch {
1049
+ commentContents = []; // 旧版 provider CLI 可能没有 comment list;回退 trace 内取证
1050
+ }
860
1051
  const texts = messages.filter((item) => optionalString(item.type) === 'text')
861
1052
  .map((item) => optionalString(item.content)).filter(Boolean);
862
1053
  const finalText = texts.at(-1) || '';
863
- const matchesResponse = (value) => smokeResponseMatches(value, marker, requiredSkills);
864
- const responseWrites = toolUses.filter((item) => optionalString(item.tool) === 'write')
865
- .map((item) => smokeToolInput(item)).filter((input) => safeSmokeResponsePath(optionalString(input.filePath)))
866
- .map((input) => optionalString(input.content)).filter(Boolean);
867
- const responseShells = toolUses.filter((item) => optionalString(item.tool) === 'bash')
868
- .map((item) => optionalString(smokeToolInput(item).command))
869
- .filter((command) => safeCombinedSmokeResponse(command, issueId, marker, requiredSkills))
870
- .map(() => JSON.stringify({ schema: 'dta-multica-load-smoke@1', marker, loaded: requiredSkills }));
871
- const responseEvidence = [...texts, ...responseWrites, ...responseShells]
1054
+ const responseWrites = [];
1055
+ const responseShells = [];
1056
+ for (const item of toolUses) {
1057
+ const message = {
1058
+ tool: optionalString(item.tool), input: smokeToolInput(item),
1059
+ };
1060
+ for (const vocabulary of active) {
1061
+ const write = vocabulary.writeEvidence(message);
1062
+ if (write && safeSmokeResponsePath(write.path))
1063
+ responseWrites.push(write.content);
1064
+ const text = vocabulary.commandText(message);
1065
+ if (text && safeCombinedSmokeResponse(text, issueId, marker, requiredSkills)) {
1066
+ responseShells.push(JSON.stringify({
1067
+ schema: 'dta-multica-load-smoke@1', marker, loaded: requiredSkills,
1068
+ }));
1069
+ }
1070
+ }
1071
+ }
1072
+ const responseEvidence = [...commentContents, ...texts, ...responseWrites, ...responseShells]
872
1073
  .find(matchesResponse) || '';
873
1074
  const responsePassed = Boolean(responseEvidence);
874
- const toolTracePassed = toolUses.every((item) => allowedSmokeToolUse(item, issueId, marker, requiredSkills)) && stableStringify(skillNames) === stableStringify(requiredSkills);
1075
+ const toolTracePassed = skillEvidencePassed && !forbiddenTools.length && !forbiddenCommands;
875
1076
  const failures = [];
1077
+ const failureDetails = [];
876
1078
  if (taskStatus !== 'completed')
877
1079
  failures.push(`smoke.task.${taskStatus || 'unknown'}`);
878
- if (!toolTracePassed)
879
- failures.push('smoke.skill-tool-trace');
880
- if (!responsePassed)
1080
+ if (!skillEvidencePassed) {
1081
+ failures.push('smoke.skill-load-evidence');
1082
+ const attemptedMissing = requiredSkills.filter((name) => attempted.has(name) && !evidence.has(name));
1083
+ failureDetails.push({
1084
+ code: 'smoke.skill-load-evidence',
1085
+ expected: requiredSkills,
1086
+ actual: loadedSkills,
1087
+ hint: attemptedMissing.length
1088
+ ? `有装载尝试但无成功证据(tool_result 缺 **Skill loaded**): ${attemptedMissing.join(', ')};` +
1089
+ '尝试不算装载成功'
1090
+ : unrecognizedTools.length
1091
+ ? `轨迹里有词汇表不认识的工具名: ${unrecognizedTools.join(', ')};` +
1092
+ '若属新 runtime 词汇,需要在 multica-runtime-vocabulary 注册'
1093
+ : '轨迹中没有这些 Skill 的装载证据(tool_result 的 **Skill loaded** 或 Skill 工具调用)',
1094
+ });
1095
+ }
1096
+ for (const tool of forbiddenTools)
1097
+ failures.push(`smoke.forbidden-tool:${tool}`);
1098
+ if (forbiddenCommands)
1099
+ failures.push('smoke.forbidden-command:dws');
1100
+ if (!responsePassed) {
881
1101
  failures.push('smoke.response');
1102
+ failureDetails.push({
1103
+ code: 'smoke.response',
1104
+ actual: `comments=${commentContents.length} texts=${texts.length} writes=${responseWrites.length}`,
1105
+ hint: '未在 Issue comment、回复文本或落盘文件中找到精确的 dta-multica-load-smoke@1 JSON',
1106
+ });
1107
+ }
1108
+ // 分类冷沙箱竞态:任务终态且缺装载证据,轨迹里出现 skill not found 类措辞 → 可重试,
1109
+ // 与「配置/模板真错了」区分开(B7 的两个误导方向都来自不区分这两态)。
1110
+ let classification = '';
1111
+ if (!skillEvidencePassed) {
1112
+ const searchable = messages.map((item) => optionalString(item.content)).join('\n');
1113
+ if (/skill[^\n]{0,80}?not\s+found|not\s+found[^\n]{0,60}?skill|skill\s+view\s+failed/i.test(searchable)) {
1114
+ classification = 'skills-not-visible';
1115
+ failures.unshift('smoke.skills-not-visible');
1116
+ failureDetails.unshift({
1117
+ code: 'smoke.skills-not-visible',
1118
+ hint: '推送的 Skill 尚未物化进沙箱(冷启动竞态,通常数分钟内自愈);' +
1119
+ '用 deploy --status --execute --yes 重试,或部署时用 --wait 放宽预算',
1120
+ });
1121
+ }
1122
+ }
882
1123
  return {
883
1124
  status: failures.length ? 'failed' : 'passed', marker, markerHash: sha256(marker),
884
1125
  issueId, taskId, taskStatus, taskDiagnostic: remoteTaskDiagnostic(run), requiredSkills, loadedSkills,
885
1126
  toolTracePassed, responsePassed, responseHash: sha256(responseEvidence || finalText), failures,
1127
+ runtimeProvider: provider, vocabularyIds, toolNames, unrecognizedTools, classification,
1128
+ failureDetails,
886
1129
  };
887
1130
  }
888
1131
  function smokeToolInput(item) {
@@ -900,46 +1143,6 @@ function smokeResponseMatches(value, marker, requiredSkills) {
900
1143
  return false;
901
1144
  }
902
1145
  }
903
- function allowedSmokeToolUse(item, issueId, marker, requiredSkills) {
904
- const tool = optionalString(item.tool);
905
- if (tool === 'skill')
906
- return true;
907
- const input = smokeToolInput(item);
908
- if (tool === 'write') {
909
- return safeSmokeResponsePath(optionalString(input.filePath)) &&
910
- smokeResponseMatches(optionalString(input.content), marker, requiredSkills);
911
- }
912
- if (tool !== 'bash')
913
- return false;
914
- const command = optionalString(input.command);
915
- if (new Set([
916
- `multica issue get ${issueId} --output json`,
917
- `multica issue metadata list ${issueId} --output json`,
918
- `multica issue comment list ${issueId} --recent 10 --output json`,
919
- `multica issue status ${issueId} in_progress`,
920
- `multica issue status ${issueId} in_review`,
921
- ]).has(command))
922
- return true;
923
- const commentPrefix = `multica issue comment add ${issueId} --content-file `;
924
- if (command.startsWith(commentPrefix)) {
925
- return safeSmokeResponsePath(command.slice(commentPrefix.length));
926
- }
927
- const cleanupFirstPrefix = 'rm ';
928
- const cleanupFirstSuffix = ` && multica issue status ${issueId} in_review`;
929
- if (command.startsWith(cleanupFirstPrefix) && command.endsWith(cleanupFirstSuffix)) {
930
- return safeSmokeCleanup(command.slice(cleanupFirstPrefix.length, command.length - cleanupFirstSuffix.length));
931
- }
932
- const statusFirstPrefix = `multica issue status ${issueId} in_review && rm `;
933
- if (command.startsWith(statusFirstPrefix)) {
934
- return safeSmokeCleanup(command.slice(statusFirstPrefix.length));
935
- }
936
- if (command.startsWith(cleanupFirstPrefix)) {
937
- return safeSmokeCleanup(command.slice(cleanupFirstPrefix.length));
938
- }
939
- if (safeCombinedSmokeResponse(command, issueId, marker, requiredSkills))
940
- return true;
941
- return false;
942
- }
943
1146
  function safeCombinedSmokeResponse(command, issueId, marker, requiredSkills) {
944
1147
  const response = JSON.stringify({ schema: 'dta-multica-load-smoke@1', marker, loaded: requiredSkills });
945
1148
  return command === [
@@ -963,9 +1166,6 @@ function safeSmokeResponsePath(value) {
963
1166
  return value.split('/').every((part) => !part ||
964
1167
  (part !== '.' && part !== '..' && /^[A-Za-z0-9._-]+$/.test(part)));
965
1168
  }
966
- function safeSmokeCleanup(value) {
967
- return safeSmokeResponsePath(value.startsWith('-f ') ? value.slice(3) : value);
968
- }
969
1169
  function rollbackConfirmed(root, plan, command, env, timeoutMs, calls, rollback, temp) {
970
1170
  const report = emptyRollback(true);
971
1171
  const attempt = (id, argv) => {
@@ -1079,7 +1279,7 @@ function verifyRollback(root, plan, command, env, timeoutMs, calls, rollback, re
1079
1279
  });
1080
1280
  }
1081
1281
  }
1082
- function failureReceipt(root, workspace, source, plan, preflight, operation, calls, rollback, status, failure) {
1282
+ function failureReceipt(root, workspace, source, plan, preflight, operation, calls, rollback, status, failure, failureDetail) {
1083
1283
  const receiptId = `multica_receipt_${digest(stableStringify({
1084
1284
  operationId: operation.operationId, status, failure,
1085
1285
  })).slice(0, 24)}`;
@@ -1117,6 +1317,7 @@ function failureReceipt(root, workspace, source, plan, preflight, operation, cal
1117
1317
  smoke: emptySmoke('not-run', source.skills.map((item) => item.name)),
1118
1318
  rollback,
1119
1319
  failures: [failure, ...rollback.failures],
1320
+ failureDetails: failureDetail ? [failureDetail] : [],
1120
1321
  evidencePath: receiptRelativePath(workspace.name, receiptId),
1121
1322
  remoteRead: true,
1122
1323
  remoteWrite: calls.some((call) => call.kind === 'write' || call.kind === 'rollback'),
@@ -1136,6 +1337,7 @@ export function statusMulticaDeployment(start, name, options = {}) {
1136
1337
  workspace: name, provider: 'multica', available: false, operationId: '', receiptId: '',
1137
1338
  status: 'missing', passed: false, providerReady: false,
1138
1339
  operationIntegrity: false, receiptIntegrity: false, reconciled: false,
1340
+ receiptFrozen: false, smokeStale: false, nextAction: '',
1139
1341
  failures: ['deployment.missing'], receipt: null,
1140
1342
  remoteRead: false, remoteWrite: false, triggerWrite: false,
1141
1343
  sideEffect: false, dingtalkSideEffect: false,
@@ -1143,6 +1345,7 @@ export function statusMulticaDeployment(start, name, options = {}) {
1143
1345
  let { operation, receipt } = loaded;
1144
1346
  let reconciled = false;
1145
1347
  let remoteRead = false;
1348
+ let remoteWrite = false;
1146
1349
  if (options.execute) {
1147
1350
  if (!options.yes)
1148
1351
  throw new Error('deploy --status 远端 reconcile 必须同时传 --execute --yes');
@@ -1151,12 +1354,41 @@ export function statusMulticaDeployment(start, name, options = {}) {
1151
1354
  const timeoutMs = positiveTimeout(options.timeoutMs, 30_000, 300_000);
1152
1355
  if (receipt.status === 'verifying' && receipt.smoke.issueId) {
1153
1356
  remoteRead = true;
1154
- const smokeReadback = readSmokeFromReceipt(info.root, receipt, command, env, timeoutMs);
1155
- const smoke = smokeReadback.smoke;
1156
- receipt = { ...receipt, calls: smokeReadback.calls, smoke, status: smoke.status === 'passed' ? 'ready'
1157
- : smoke.status === 'pending' ? 'verifying' : 'failed',
1357
+ let smokeReadback = readSmokeFromReceipt(info.root, receipt, command, env, timeoutMs);
1358
+ let smoke = smokeReadback.smoke;
1359
+ let calls = smokeReadback.calls;
1360
+ // 冷沙箱竞态的收口入口:终态失败但分类为 skills-not-visible 时,补一次 mention
1361
+ // 触发新 task(唯一的写;如实上报 remoteWrite/sideEffect)。新 run 是异步创建的:
1362
+ // 以 taskId 变化为「新 run 出现」的判据再判定,等不到就按旧判定收场(仍 verifying),
1363
+ // 不再重复 mention。
1364
+ if (smoke.status === 'failed' && smoke.classification === 'skills-not-visible' &&
1365
+ receipt.agent.id) {
1366
+ const planLike = {
1367
+ profile: receipt.profile, expected: { workspaceId: operation.workspaceId },
1368
+ };
1369
+ const staleTaskId = smoke.taskId;
1370
+ runProvider(command, scopedArgs(planLike, [
1371
+ 'issue', 'comment', 'add', receipt.smoke.issueId,
1372
+ '--content', `[@DTA load smoke](mention://agent/${receipt.agent.id}) Skills were not yet visible in the sandbox; re-run the deployment verification exactly as specified in this Issue description.`,
1373
+ '--output', 'json',
1374
+ ]), 'smoke.issue.retry', 'write', info.root, env, timeoutMs, calls);
1375
+ remoteWrite = true;
1376
+ const deadline = Date.now() + timeoutMs;
1377
+ do {
1378
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2_000);
1379
+ smokeReadback = readSmokeFromReceipt(info.root, { ...receipt, calls }, command, env, timeoutMs);
1380
+ smoke = smokeReadback.smoke;
1381
+ calls = smokeReadback.calls;
1382
+ } while ((smoke.status === 'pending' ||
1383
+ (Boolean(staleTaskId) && smoke.taskId === staleTaskId)) && Date.now() < deadline);
1384
+ }
1385
+ const nextStatus = smoke.status === 'passed' ? 'ready'
1386
+ : smoke.status === 'pending' ? 'verifying'
1387
+ : smoke.classification === 'skills-not-visible' ? 'verifying' : 'failed';
1388
+ receipt = { ...receipt, calls, smoke, status: nextStatus,
1158
1389
  passed: smoke.status === 'passed', providerReady: smoke.status === 'passed',
1159
- failures: [...smoke.failures] };
1390
+ failures: [...smoke.failures],
1391
+ failureDetails: [...smoke.failureDetails] };
1160
1392
  reconciled = true;
1161
1393
  persistReceiptAndState(info.root, showDevelopmentWorkspace(info.root, name), operation, receipt);
1162
1394
  }
@@ -1190,6 +1422,7 @@ export function statusMulticaDeployment(start, name, options = {}) {
1190
1422
  }
1191
1423
  operation = loadOperationAndReceipt(info.root, name, operation.operationId)?.operation || operation;
1192
1424
  }
1425
+ const receiptFrozen = !options.execute;
1193
1426
  return {
1194
1427
  $schema: 'dingtalk-agent/multica-deployment-status@1',
1195
1428
  workspace: name,
@@ -1203,12 +1436,17 @@ export function statusMulticaDeployment(start, name, options = {}) {
1203
1436
  operationIntegrity: true,
1204
1437
  receiptIntegrity: true,
1205
1438
  reconciled,
1439
+ receiptFrozen,
1440
+ smokeStale: receiptFrozen && receipt.status === 'verifying',
1441
+ nextAction: ['verifying', 'reconciling'].includes(receipt.status)
1442
+ ? `dta deploy --workspace ${name} --status --operation-id ${operation.operationId} --execute --yes`
1443
+ : '',
1206
1444
  failures: [...receipt.failures],
1207
1445
  receipt,
1208
1446
  remoteRead,
1209
- remoteWrite: false,
1447
+ remoteWrite,
1210
1448
  triggerWrite: false,
1211
- sideEffect: remoteRead ? 'multica-read' : false,
1449
+ sideEffect: remoteWrite ? 'multica-read-write' : remoteRead ? 'multica-read' : false,
1212
1450
  dingtalkSideEffect: false,
1213
1451
  };
1214
1452
  }
@@ -1334,7 +1572,7 @@ function readSmokeFromReceipt(root, receipt, command, env, timeoutMs) {
1334
1572
  failures: ['smoke.marker-not-recoverable'],
1335
1573
  } };
1336
1574
  }
1337
- const smoke = readSmoke(root, plan, markerValue, receipt.smoke.issueId, receipt.agent.id, receipt.smoke.requiredSkills, command, env, timeoutMs, calls);
1575
+ const smoke = readSmoke(root, plan, markerValue, receipt.smoke.issueId, receipt.agent.id, receipt.smoke.requiredSkills, receipt.smoke.runtimeProvider || '', command, env, timeoutMs, calls);
1338
1576
  return { smoke, calls };
1339
1577
  }
1340
1578
  function persistReceiptAndState(root, workspace, operation, receipt) {
@@ -1504,7 +1742,7 @@ function runProvider(command, argv, id, kind, cwd, env, timeoutMs, calls) {
1504
1742
  throw new DeploymentFailure('budget.rollback-exhausted');
1505
1743
  }
1506
1744
  const run = spawnSync(command, argv, {
1507
- cwd, env, encoding: 'utf8', timeout: timeoutMs,
1745
+ cwd, env: sanitizeProviderEnv(env).env, encoding: 'utf8', timeout: timeoutMs,
1508
1746
  maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
1509
1747
  });
1510
1748
  const stdout = String(run.stdout || '');
@@ -1516,8 +1754,10 @@ function runProvider(command, argv, id, kind, cwd, env, timeoutMs, calls) {
1516
1754
  id, kind, argv: redactArgv(argv), exitCode,
1517
1755
  stdoutHash: sha256(stdout), stderrHash: sha256(stderr), passed, ambiguous,
1518
1756
  });
1519
- if (!passed)
1757
+ if (!passed) {
1758
+ emitProviderDiagnostic(id, exitCode, stderr || stdout);
1520
1759
  throw new DeploymentFailure(`command.${id}`, ambiguous);
1760
+ }
1521
1761
  return { stdout, stderr };
1522
1762
  }
1523
1763
  function scopedArgs(plan, args) {
@@ -1638,6 +1878,8 @@ function emptySmoke(status, requiredSkills) {
1638
1878
  status, marker: '', markerHash: '', issueId: '', taskId: '', taskStatus: '', taskDiagnostic: '',
1639
1879
  requiredSkills: [...requiredSkills].sort(), loadedSkills: [],
1640
1880
  toolTracePassed: false, responsePassed: false, responseHash: '', failures: [],
1881
+ runtimeProvider: '', vocabularyIds: [], toolNames: [], unrecognizedTools: [],
1882
+ classification: '', failureDetails: [],
1641
1883
  };
1642
1884
  }
1643
1885
  function emptyRollback(attempted) {