@xdxer/dingtalk-agent 0.1.5-beta.1 → 0.1.5-beta.10

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 (175) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.en.md +99 -66
  3. package/README.md +99 -66
  4. package/dist/bin/dingtalk-agent.js +735 -149
  5. package/dist/bin/dingtalk-agent.js.map +1 -1
  6. package/dist/src/actions.js +3 -2
  7. package/dist/src/actions.js.map +1 -1
  8. package/dist/src/agent-audit.js +202 -85
  9. package/dist/src/agent-audit.js.map +1 -1
  10. package/dist/src/agent-definition.js +7 -3
  11. package/dist/src/agent-definition.js.map +1 -1
  12. package/dist/src/agent-enhance.js +51 -32
  13. package/dist/src/agent-enhance.js.map +1 -1
  14. package/dist/src/agent-platform.js +3 -3
  15. package/dist/src/agent-platform.js.map +1 -1
  16. package/dist/src/bootstrap.js +6 -2
  17. package/dist/src/bootstrap.js.map +1 -1
  18. package/dist/src/development-workspace.js +210 -34
  19. package/dist/src/development-workspace.js.map +1 -1
  20. package/dist/src/doctor.js +65 -9
  21. package/dist/src/doctor.js.map +1 -1
  22. package/dist/src/dws.js +67 -3
  23. package/dist/src/dws.js.map +1 -1
  24. package/dist/src/init.js +2 -1
  25. package/dist/src/init.js.map +1 -1
  26. package/dist/src/memory/noop-receipt.js +306 -0
  27. package/dist/src/memory/noop-receipt.js.map +1 -0
  28. package/dist/src/memory/operational.js +27 -3
  29. package/dist/src/memory/operational.js.map +1 -1
  30. package/dist/src/memory/remote-state.js +2 -1
  31. package/dist/src/memory/remote-state.js.map +1 -1
  32. package/dist/src/multica-deploy.js +692 -125
  33. package/dist/src/multica-deploy.js.map +1 -1
  34. package/dist/src/multica-provider.js +303 -25
  35. package/dist/src/multica-provider.js.map +1 -1
  36. package/dist/src/multica-runtime-vocabulary.js +110 -0
  37. package/dist/src/multica-runtime-vocabulary.js.map +1 -0
  38. package/dist/src/opencode-evals.js +6 -6
  39. package/dist/src/opencode-evals.js.map +1 -1
  40. package/dist/src/opencode-provider.js +21 -7
  41. package/dist/src/opencode-provider.js.map +1 -1
  42. package/dist/src/opencode-workspace.js +3 -3
  43. package/dist/src/opencode-workspace.js.map +1 -1
  44. package/dist/src/personal-event-evals.js +4 -2
  45. package/dist/src/personal-event-evals.js.map +1 -1
  46. package/dist/src/promotion.js +2 -1
  47. package/dist/src/promotion.js.map +1 -1
  48. package/dist/src/remote-semantic-state-live-evals.js +14 -8
  49. package/dist/src/remote-semantic-state-live-evals.js.map +1 -1
  50. package/dist/src/remote-state-evals.js +2 -2
  51. package/dist/src/remote-state-evals.js.map +1 -1
  52. package/dist/src/robot-evals.js +3 -3
  53. package/dist/src/robot-evals.js.map +1 -1
  54. package/dist/src/schedule-plan.js +380 -0
  55. package/dist/src/schedule-plan.js.map +1 -0
  56. package/dist/src/sessions.js +1 -1
  57. package/dist/src/sessions.js.map +1 -1
  58. package/dist/src/skill-manager.js +145 -13
  59. package/dist/src/skill-manager.js.map +1 -1
  60. package/dist/src/skills.js +2 -0
  61. package/dist/src/skills.js.map +1 -1
  62. package/dist/src/tui.js +369 -0
  63. package/dist/src/tui.js.map +1 -0
  64. package/dist/src/upgrade.js +113 -33
  65. package/dist/src/upgrade.js.map +1 -1
  66. package/dist/src/waits.js +2 -1
  67. package/dist/src/waits.js.map +1 -1
  68. package/dist/src/workspace.js +12 -7
  69. package/dist/src/workspace.js.map +1 -1
  70. package/docs/AGENT-IN-PRODUCTION.md +255 -0
  71. package/docs/ARCHITECTURE.md +165 -31
  72. package/docs/INSTALLATION.md +7 -7
  73. package/docs/PLATFORM-GUARDRAILS.md +188 -0
  74. package/docs/PRIOR-ART.md +4 -0
  75. package/docs/SELF-TEST.md +4 -4
  76. package/docs/architecture/agent-platform-connection-layer.svg +120 -0
  77. package/docs/architecture/digital-employee-composition.svg +92 -0
  78. package/docs/architecture/dingtalk-agent-architecture.svg +125 -0
  79. package/docs/assets/digital-employee-at-work.svg +77 -0
  80. package/docs/schemas/multica-deployment-plan.schema.json +3 -1
  81. package/docs/schemas/multica-deployment-receipt.schema.json +17 -3
  82. package/docs/schemas/multica-deployment-status.schema.json +6 -2
  83. package/docs/schemas/multica-workspace-inspection.schema.json +16 -0
  84. package/docs/schemas/multica-workspace-run-plan.schema.json +31 -0
  85. package/docs/schemas/multica-workspace-run.schema.json +161 -0
  86. package/docs/schemas/multica-workspace-status.schema.json +2 -0
  87. package/docs/schemas/project.schema.json +54 -3
  88. package/docs/schemas/workspace-scaffold.schema.json +38 -0
  89. package/examples/agents/README.md +10 -8
  90. package/examples/agents/fde-coach/AGENTS.md +2 -34
  91. package/examples/agents/fde-coach/agent/AGENTS.md +35 -0
  92. package/examples/agents/fde-coach/agent.bindings.json +10 -0
  93. package/examples/agents/release-manager/AGENTS.md +2 -34
  94. package/examples/agents/release-manager/agent/AGENTS.md +35 -0
  95. package/examples/agents/release-manager/agent.bindings.json +10 -0
  96. package/lab/agent-eval/catalog.json +5 -5
  97. package/lab/agent-eval/classic-failures.json +4 -4
  98. package/lab/agent-eval/completion-gate-regression.json +9 -9
  99. package/lab/agent-eval/personal-event-live.example.json +3 -3
  100. package/lab/agent-eval/remote-semantic-state-live.example.json +1 -1
  101. package/lab/agent-eval/workspace/opencode.json +2 -2
  102. package/lab/project-workspace/fake-multica-provider.mjs +171 -17
  103. package/lab/project-workspace/multica-deploy.fixture.json +2 -2
  104. package/lab/project-workspace/multica-readonly.fixture.json +4 -16
  105. package/lab/project-workspace/opencode-provider-suite.json +3 -3
  106. package/lab/project-workspace/project.fixture.json +2 -6
  107. package/lab/robot-eval/suite.json +1 -1
  108. package/lab/robot-eval/workspace/AGENTS.md +1 -1
  109. package/lab/robot-eval/workspace/opencode.json +2 -2
  110. package/lab/schemas/personal-event-eval.schema.json +1 -1
  111. package/package.json +12 -9
  112. package/skills/README.md +10 -8
  113. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/SKILL.md +48 -23
  114. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/assets/AGENTS.template.md +1 -1
  115. package/skills/core/dta-agent-compose/assets/REPOSITORY.template.md +10 -0
  116. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/assets/agent.bindings.dingtalk-doc.template.json +2 -2
  117. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/assets/agent.bindings.local.template.json +2 -2
  118. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/assets/hosts/opencode/opencode.template.json +3 -2
  119. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/evals/evals.json +4 -4
  120. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/references/agent-definition-contract.md +7 -7
  121. package/skills/core/dta-agent-compose/references/drive-and-schedules.md +166 -0
  122. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/references/host-loading-contract.md +11 -13
  123. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/references/hosts/claude-code.md +13 -12
  124. package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/references/hosts/opencode.md +14 -13
  125. package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/SKILL.md +2 -2
  126. package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/assets/eval-catalog.template.json +1 -1
  127. package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/evals/evals.json +1 -1
  128. package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/references/eval-topology.md +3 -3
  129. package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/references/interactive-debug-channels.md +10 -4
  130. package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/SKILL.md +21 -5
  131. package/skills/core/dta-basic-behavior/references/event-to-behavior.md +38 -0
  132. package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/references/memory-and-evolution.md +3 -1
  133. package/skills/core/dta-basic-behavior/references/perception-and-gates.md +87 -0
  134. package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/references/risk-authority-and-privacy.md +12 -0
  135. package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/references/truth-and-recovery.md +4 -2
  136. package/skills/core/dta-people-group-memory/COMPLETENESS.md +36 -0
  137. package/skills/core/dta-people-group-memory/SKILL.md +69 -0
  138. package/skills/core/dta-people-group-memory/references/adapters.md +273 -0
  139. package/skills/core/dta-people-group-memory/references/assembly-guidance.md +40 -0
  140. package/skills/core/dta-people-group-memory/references/binding.md +110 -0
  141. package/skills/core/dta-people-group-memory/references/cold-start.md +70 -0
  142. package/skills/core/dta-people-group-memory/references/config-binding.md +89 -0
  143. package/skills/core/dta-people-group-memory/references/consent-and-visibility.md +83 -0
  144. package/skills/core/dta-people-group-memory/references/consolidation.md +162 -0
  145. package/skills/core/dta-people-group-memory/references/event-ingest.md +103 -0
  146. package/skills/core/dta-people-group-memory/references/guided-setup.md +70 -0
  147. package/skills/core/dta-people-group-memory/references/model.md +148 -0
  148. package/skills/core/dta-people-group-memory/references/storage-port.md +107 -0
  149. package/skills/platforms/deap/PLATFORM.md +30 -1
  150. package/skills/platforms/multica-dingtalk/PLATFORM.md +34 -8
  151. package/skills/platforms/multica-dingtalk/{dingtalk-agent-deploy-multica → dta-deploy-multica}/SKILL.md +10 -8
  152. package/skills/platforms/multica-dingtalk/dta-deploy-multica/references/multica-deployment-contract.md +67 -0
  153. package/skills/platforms/multica-dingtalk/{multica-external → dta-ops-multica}/SKILL.md +81 -11
  154. package/skills/platforms/multica-dingtalk/{multica-external → dta-ops-multica}/scripts/bootstrap.sh +2 -2
  155. package/skills/platforms/multica-dingtalk/{multica-external → dta-ops-multica}/scripts/multica_ext.py +264 -16
  156. package/docs/assets/agent-delivery-lifecycle.svg +0 -103
  157. package/skills/core/dingtalk-basic-behavior/references/event-to-behavior.md +0 -24
  158. package/skills/core/dingtalk-basic-behavior/references/perception-and-gates.md +0 -28
  159. package/skills/platforms/multica-dingtalk/dingtalk-agent-boot-multica/SKILL.md +0 -40
  160. package/skills/platforms/multica-dingtalk/dingtalk-agent-deploy-multica/references/multica-deployment-contract.md +0 -49
  161. /package/examples/agents/fde-coach/{skills → agent/skills}/fde-coach/SKILL.md +0 -0
  162. /package/examples/agents/release-manager/{skills → agent/skills}/release-manager/SKILL.md +0 -0
  163. /package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/assets/role-skill.template.md +0 -0
  164. /package/skills/core/{dingtalk-agent-compose → dta-agent-compose}/references/storage-routing.md +0 -0
  165. /package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/references/evidence-contract.md +0 -0
  166. /package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/references/failure-to-case.md +0 -0
  167. /package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/references/local-connector-smoke.md +0 -0
  168. /package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/references/scenario-taxonomy.md +0 -0
  169. /package/skills/core/{dingtalk-agent-eval → dta-agent-eval}/references/storage-modes.md +0 -0
  170. /package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/assets/memory-candidate-proposal.json +0 -0
  171. /package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/assets/task-checkpoint.json +0 -0
  172. /package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/references/action-contract.md +0 -0
  173. /package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/references/runtime-modes.md +0 -0
  174. /package/skills/core/{dingtalk-basic-behavior → dta-basic-behavior}/references/task-lifecycle.md +0 -0
  175. /package/skills/platforms/multica-dingtalk/{dingtalk-agent-deploy-multica → dta-deploy-multica}/references/promotion-observation-contract.md +0 -0
@@ -3,11 +3,10 @@ import { spawnSync } from 'node:child_process';
3
3
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, dirname, join, relative, resolve } from 'node:path';
6
- import { WORKSPACE_STATE_ROOT, inspectAgentProject, showDevelopmentWorkspace, } from './development-workspace.js';
6
+ import { WORKSPACE_STATE_ROOT, inspectAgentProject, projectSkillRef, showDevelopmentWorkspace, } from './development-workspace.js';
7
7
  import { digest, stableStringify } from './events.js';
8
- import { MULTICA_BOOT_SKILL, MULTICA_EVIDENCE_ROOT, inspectMulticaWorkspace, planMulticaWorkspace, statusMulticaWorkspace, } from './multica-provider.js';
9
- import { resolvePackageRoot } from './package-root.js';
10
- import { bundledSkillPath } from './skill-manager.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';
11
10
  export const MULTICA_DEPLOYMENT_SCHEMA = 'dingtalk-agent/multica-deployment-receipt@1';
12
11
  const MAX_DEPLOY_SKILLS = 32;
13
12
  const MAX_SKILL_SUPPORTING_FILES = 128;
@@ -15,14 +14,293 @@ const deploymentWriteBudgets = new WeakMap();
15
14
  class DeploymentFailure extends Error {
16
15
  category;
17
16
  ambiguous;
18
- constructor(category, ambiguous = false) {
17
+ detail;
18
+ constructor(category, ambiguous = false, detail) {
19
19
  super(category);
20
20
  this.category = category;
21
21
  this.ambiguous = ambiguous;
22
+ this.detail = detail;
22
23
  }
23
24
  }
25
+ export function runMulticaWorkspaceIssue(start, name, prompt, options = {}) {
26
+ const normalizedPrompt = prompt.trim();
27
+ if (!normalizedPrompt)
28
+ throw new Error('workspace run prompt 不能为空');
29
+ if (Buffer.byteLength(normalizedPrompt, 'utf8') > 64 * 1024) {
30
+ throw new Error('workspace run prompt 不能超过 65536 bytes');
31
+ }
32
+ rejectCredentialMaterial(normalizedPrompt, 'workspace run prompt');
33
+ const env = options.env || process.env;
34
+ const cliVersion = options.cliVersion || '';
35
+ const info = inspectAgentProject(start, cliVersion);
36
+ requireMulticaWorkspace(info.root, name, cliVersion);
37
+ const providerPlan = planMulticaWorkspace(info.root, name, { env, cliVersion });
38
+ const blocking = [...providerPlan.blocking];
39
+ if (!providerPlan.expected.agentId)
40
+ blocking.push('agent-id.missing');
41
+ const plan = {
42
+ $schema: 'dingtalk-agent/multica-workspace-run-plan@1',
43
+ workspace: name,
44
+ provider: 'multica',
45
+ profile: providerPlan.profile,
46
+ workspaceId: providerPlan.expected.workspaceId,
47
+ runtimeId: providerPlan.expected.runtimeId,
48
+ agentId: providerPlan.expected.agentId,
49
+ promptHash: sha256(normalizedPrompt),
50
+ operations: ['scope.inspect', 'issue.create', 'issue.runs', 'issue.run-messages', 'evidence.persist'],
51
+ blocking: [...new Set(blocking)].sort(),
52
+ requiresExecute: true,
53
+ requiresExplicitYes: true,
54
+ remoteRead: false,
55
+ remoteWrite: false,
56
+ triggerWrite: false,
57
+ sideEffect: false,
58
+ dingtalkSideEffect: false,
59
+ };
60
+ if (!options.execute)
61
+ return plan;
62
+ if (!options.yes) {
63
+ throw new Error('Multica workspace run 会创建远端 Issue;必须同时传 --execute --yes');
64
+ }
65
+ if (plan.blocking.length) {
66
+ throw new Error(`Multica workspace run 前置条件不完整: ${plan.blocking.join(', ')}`);
67
+ }
68
+ const command = options.command || env.DTA_MULTICA_BIN || 'multica';
69
+ // A real Issue-channel run does agent work plus issue get/comment/status side-tasks and
70
+ // routinely needs minutes; the old 30s default timed out on almost every honest run. Wait
71
+ // 4 minutes by default (still capped at 5). A run that overruns is resumable via
72
+ // `workspace run --status --run-id` against the same Issue — no duplicate Issue.
73
+ const timeoutMs = positiveTimeout(options.timeoutMs, 240_000, 300_000);
74
+ const calls = [];
75
+ const inspection = inspectMulticaWorkspace(info.root, name, {
76
+ execute: true, yes: true, persist: false, command, env,
77
+ timeoutMs: Math.min(timeoutMs, 30_000), cliVersion,
78
+ });
79
+ appendInspectionCalls(inspection, 'run.preflight', calls);
80
+ const assignedSkills = inspection.resources.assignedSkills
81
+ .filter((item) => item.enabled).map((item) => item.name).sort();
82
+ const expectedSkills = [...providerPlan.expected.skills].sort();
83
+ if (!inspection.passed || inspection.resources.workspace?.id !== plan.workspaceId ||
84
+ inspection.resources.runtime?.id !== plan.runtimeId ||
85
+ inspection.resources.agent?.id !== plan.agentId ||
86
+ inspection.diff.includes('agent.runtime-drift') ||
87
+ stableStringify(assignedSkills) !== stableStringify(expectedSkills)) {
88
+ throw new Error('Multica workspace run scope 或 Skill assignment 与 Project 不一致;未创建 Issue');
89
+ }
90
+ const runId = `multica_workspace_run_${Date.now()}_${randomUUID().slice(0, 8)}`;
91
+ const marker = `DTA-WORKSPACE-RUN-${randomUUID()}`;
92
+ const issue = jsonObject(runProvider(command, scopedArgs({ profile: plan.profile, expected: { workspaceId: plan.workspaceId } }, [
93
+ 'issue', 'create', '--title', `[DTA workspace run] ${marker}`,
94
+ '--description', normalizedPrompt, '--assignee-id', plan.agentId, '--output', 'json',
95
+ ]), 'workspace-run.issue.create', 'write', info.root, env, Math.min(timeoutMs, 30_000), calls).stdout, 'workspace run issue');
96
+ const issueId = resourceId(issue.id, 'workspace run issue.id');
97
+ return readbackWorkspaceRun({
98
+ root: info.root, name, plan, issueId, runId, command, env, timeoutMs, calls, out: options.out,
99
+ runtimeProvider: inspection.resources.runtime?.provider || '',
100
+ });
101
+ }
102
+ /**
103
+ * Poll an existing Issue's assignee-agent task to a terminal state, then reconstruct the
104
+ * run report from its trace. Shared by the initial `workspace run` (right after it creates the
105
+ * Issue) and `workspace run --status --run-id` (resume against the SAME Issue, no new write) so
106
+ * a timed-out run reconciles instead of spawning a duplicate Issue.
107
+ */
108
+ function readbackWorkspaceRun(ctx) {
109
+ const { root, name, plan, issueId, runId, command, env, timeoutMs, calls } = ctx;
110
+ const started = Date.now();
111
+ const pollIntervalMs = 2_000;
112
+ const pollCalls = [];
113
+ let pollAttempts = 0;
114
+ let task;
115
+ while (Date.now() - started < timeoutMs) {
116
+ pollAttempts += 1;
117
+ const runs = jsonArray(runProvider(command, scopedArgs({ profile: plan.profile, expected: { workspaceId: plan.workspaceId } }, [
118
+ 'issue', 'runs', issueId, '--full-id', '--output', 'json',
119
+ ]), 'workspace-run.issue.runs', 'read', root, env, Math.min(timeoutMs, 30_000), pollCalls).stdout, 'workspace run issue runs');
120
+ task = runs.filter((item) => !optionalString(item.agent_id) ||
121
+ optionalString(item.agent_id) === plan.agentId)
122
+ .sort((a, b) => optionalString(b.created_at).localeCompare(optionalString(a.created_at)))[0];
123
+ const status = optionalString(task?.status);
124
+ if (['completed', 'failed', 'cancelled'].includes(status))
125
+ break;
126
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, pollIntervalMs);
127
+ }
128
+ calls.push(...uniqueCalls(pollCalls));
129
+ const taskId = task?.id ? resourceId(task.id, 'workspace run task.id') : '';
130
+ const taskStatus = optionalString(task?.status) || 'not-created';
131
+ let messages = [];
132
+ if (taskId && ['completed', 'failed', 'cancelled'].includes(taskStatus)) {
133
+ messages = jsonArray(runProvider(command, scopedArgs({ profile: plan.profile, expected: { workspaceId: plan.workspaceId } }, [
134
+ 'issue', 'run-messages', taskId, '--issue', issueId, '--output', 'json',
135
+ ]), 'workspace-run.issue.messages', 'read', root, env, Math.min(timeoutMs, 30_000), calls).stdout, 'workspace run messages');
136
+ }
137
+ const toolUses = messages.filter((item) => optionalString(item.type) === 'tool_use');
138
+ const toolNames = toolUses.map((item) => optionalString(item.tool)).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();
164
+ const texts = messages.filter((item) => optionalString(item.type) === 'text')
165
+ .map((item) => optionalString(item.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);
176
+ const answer = safeRemoteOutput(replyWrites.at(-1) || texts.at(-1) || '', 64 * 1024);
177
+ const issueReadback = jsonObject(runProvider(command, scopedArgs({ profile: plan.profile, expected: { workspaceId: plan.workspaceId } }, [
178
+ 'issue', 'get', issueId, '--output', 'json',
179
+ ]), 'workspace-run.issue.readback', 'read', root, env, Math.min(timeoutMs, 30_000), calls).stdout, 'workspace run issue readback');
180
+ const issueStatus = optionalString(issueReadback.status);
181
+ const failures = [];
182
+ if (!task)
183
+ failures.push('task.not-created');
184
+ else if (!['completed', 'failed', 'cancelled'].includes(taskStatus))
185
+ failures.push('task.timeout');
186
+ else if (taskStatus !== 'completed')
187
+ failures.push(`task.${taskStatus}`);
188
+ if (!answer)
189
+ failures.push('answer.missing');
190
+ const taskDiagnostic = task
191
+ ? remoteTaskDiagnostic(task)
192
+ : `未发现关联 task;Issue 状态=${issueStatus || 'unknown'},已轮询 ${pollAttempts} 次`;
193
+ const report = {
194
+ $schema: 'dingtalk-agent/multica-workspace-run@1',
195
+ passed: failures.length === 0,
196
+ runId,
197
+ workspace: name,
198
+ provider: 'multica',
199
+ profile: plan.profile,
200
+ workspaceId: plan.workspaceId,
201
+ runtimeId: plan.runtimeId,
202
+ agentId: plan.agentId,
203
+ promptHash: plan.promptHash,
204
+ issueId,
205
+ issueStatus,
206
+ taskId,
207
+ taskStatus,
208
+ taskDiagnostic,
209
+ pollAttempts,
210
+ pollIntervalMs,
211
+ answer,
212
+ answerHash: sha256(answer),
213
+ toolNames,
214
+ loadedSkills,
215
+ failures,
216
+ calls,
217
+ evidencePath: '',
218
+ remoteRead: true,
219
+ remoteWrite: calls.some((call) => call.kind === 'write'),
220
+ triggerWrite: false,
221
+ sideEffect: 'multica-issue-read-write',
222
+ dingtalkSideEffect: false,
223
+ };
224
+ const defaultEvidence = join(MULTICA_EVIDENCE_ROOT, safeName(name), 'multica-runs', `${runId}.json`);
225
+ report.evidencePath = relative(root, safeProjectPath(root, ctx.out || defaultEvidence, false));
226
+ assertReportRedacted(report);
227
+ atomicJson(safeProjectPath(root, report.evidencePath, false), report);
228
+ return report;
229
+ }
230
+ /**
231
+ * Resume a `workspace run` by re-reading the SAME Issue's task instead of creating a new one.
232
+ * Loads the persisted run evidence by runId, confirms the workspace scope still resolves to the
233
+ * same target (fail closed on drift), then polls that Issue's assignee-agent task to a terminal
234
+ * state and rewrites the run report. Read-only against Multica — never enqueues a second Issue.
235
+ */
236
+ export function statusMulticaWorkspaceRun(start, name, options = {}) {
237
+ const env = options.env || process.env;
238
+ const cliVersion = options.cliVersion || '';
239
+ const runId = String(options.runId || '').trim();
240
+ if (!/^multica_workspace_run_[0-9]+_[a-f0-9]{8}$/.test(runId)) {
241
+ throw new Error('workspace run --status 需要一个合法的 --run-id(来自上次 run 的 runId)');
242
+ }
243
+ if (!options.execute) {
244
+ throw new Error('workspace run --status 会向 Multica 独立回读同一 Issue;必须传 --execute');
245
+ }
246
+ if (!options.yes)
247
+ throw new Error('workspace run --status 远端回读必须同时传 --execute --yes');
248
+ const info = inspectAgentProject(start, cliVersion);
249
+ requireMulticaWorkspace(info.root, name, cliVersion);
250
+ // The original run persists evidence to `--out` when given, else the default runId path. Resume
251
+ // must look where the run actually wrote, so honor the same `--out` (and rewrite back to it).
252
+ const evidenceRel = options.out || join(MULTICA_EVIDENCE_ROOT, safeName(name), 'multica-runs', `${runId}.json`);
253
+ const evidenceAbs = safeProjectPath(info.root, evidenceRel, false);
254
+ if (!existsSync(evidenceAbs)) {
255
+ throw new Error(`找不到 run 证据 ${evidenceRel};--run-id 必须是本 Workspace 已有的 runId` +
256
+ `${options.out ? '' : '(若原 run 用了 --out,请对 --status 传相同 --out)'}`);
257
+ }
258
+ const prior = JSON.parse(readFileSync(evidenceAbs, 'utf8'));
259
+ if (prior.$schema !== 'dingtalk-agent/multica-workspace-run@1' ||
260
+ prior.provider !== 'multica' || prior.workspace !== name || !prior.issueId) {
261
+ throw new Error('run 证据不是本 Workspace 的 multica run,或缺少 issueId;已 fail closed');
262
+ }
263
+ // The evidence file must belong to THIS runId. Two runs sharing one --out overwrite each
264
+ // other, so resuming the first runId against that path would otherwise read the second run's
265
+ // Issue while reporting the first runId — cross-wiring answer and run. Fail closed on mismatch.
266
+ if (prior.runId !== runId) {
267
+ throw new Error(`run 证据 ${evidenceRel} 属于 runId ${prior.runId},与 --run-id ${runId} 不一致;已 fail closed` +
268
+ `(同一 --out 被后一次 run 覆盖时会这样)`);
269
+ }
270
+ // Re-resolve scope offline and refuse unless the WHOLE frozen target still matches: an env
271
+ // pointing at a different runtime/agent/profile (or an invalid ref surfacing in blocking) must
272
+ // not let the resume mis-record the old task or overwrite the frozen values. workspaceId alone
273
+ // is not enough.
274
+ const providerPlan = planMulticaWorkspace(info.root, name, { env, cliVersion });
275
+ const scopeDrift = providerPlan.blocking.length ? ['blocking:' + providerPlan.blocking.join(',')] : [];
276
+ if (providerPlan.profile !== prior.profile)
277
+ scopeDrift.push('profile');
278
+ if (providerPlan.expected.workspaceId !== prior.workspaceId)
279
+ scopeDrift.push('workspace');
280
+ if (providerPlan.expected.runtimeId !== prior.runtimeId)
281
+ scopeDrift.push('runtime');
282
+ if (providerPlan.expected.agentId !== prior.agentId)
283
+ scopeDrift.push('agent');
284
+ if (scopeDrift.length) {
285
+ throw new Error(`workspace run --status: 当前 workspace scope 与 run 证据不一致 [${scopeDrift.join(', ')}];已 fail closed`);
286
+ }
287
+ const command = options.command || env.DTA_MULTICA_BIN || 'multica';
288
+ const timeoutMs = positiveTimeout(options.timeoutMs, 240_000, 300_000);
289
+ const plan = {
290
+ profile: providerPlan.profile,
291
+ workspaceId: providerPlan.expected.workspaceId,
292
+ runtimeId: providerPlan.expected.runtimeId,
293
+ agentId: providerPlan.expected.agentId,
294
+ promptHash: prior.promptHash,
295
+ };
296
+ return readbackWorkspaceRun({
297
+ root: info.root, name, plan, issueId: prior.issueId, runId,
298
+ command, env, timeoutMs, calls: [], out: options.out,
299
+ });
300
+ }
24
301
  export function planMulticaDeployment(start, name, options = {}) {
25
302
  const action = options.action || 'apply';
303
+ const rebindRuntime = Boolean(options.rebindRuntime) && action === 'apply';
26
304
  const env = options.env || process.env;
27
305
  const cliVersion = options.cliVersion || '';
28
306
  const info = inspectAgentProject(start, cliVersion);
@@ -31,12 +309,21 @@ export function planMulticaDeployment(start, name, options = {}) {
31
309
  const readPlan = planMulticaWorkspace(info.root, name, { env, cliVersion });
32
310
  const blocking = [...readPlan.blocking];
33
311
  let inspectionId = '';
312
+ let previousRuntimeId = '';
34
313
  try {
35
314
  const status = statusMulticaWorkspace(info.root, name, cliVersion);
36
315
  inspectionId = status.inspectionId;
37
316
  if (!status.readyForApply || !status.evidenceIntegrity || !status.stateMatched) {
38
317
  blocking.push(...status.failures.map((item) => `inspection.${item}`));
39
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
+ }
40
327
  }
41
328
  catch {
42
329
  blocking.push('inspection.invalid');
@@ -45,26 +332,30 @@ export function planMulticaDeployment(start, name, options = {}) {
45
332
  blocking.push('inspection.missing');
46
333
  if (action === 'retire' && !readPlan.expected.agentId)
47
334
  blocking.push('agent-id.missing');
335
+ if (rebindRuntime && !readPlan.expected.agentId)
336
+ blocking.push('rebind.agent-id-missing');
48
337
  const expectedSkills = source.skills.map((skill) => ({
49
338
  name: skill.name, hash: skill.hash, files: skill.files.length + 1,
50
339
  }));
51
340
  const operations = action === 'retire'
52
341
  ? ['scope.reinspect', 'agent.archive', 'agent.readback', 'receipt.persist']
53
342
  : [
54
- 'scope.reinspect', 'skills.readback', 'skills.reconcile', 'agent.reconcile',
343
+ 'scope.reinspect', 'skills.readback', 'skills.reconcile',
344
+ ...(rebindRuntime ? ['agent.rebind-runtime'] : []), 'agent.reconcile',
55
345
  'skills.assign-exact', 'provider.readback', 'load-smoke.create',
56
- 'load-smoke.readback', 'receipt.persist',
346
+ 'load-smoke.recover-if-unstarted', 'load-smoke.readback', 'receipt.persist',
57
347
  ];
58
348
  const writeBudget = action === 'retire'
59
349
  ? { forward: 1, rollback: 0 }
60
350
  : {
61
- forward: source.skills.length * (1 + MAX_SKILL_SUPPORTING_FILES * 2) + 4,
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,
62
353
  rollback: source.skills.length * (1 + MAX_SKILL_SUPPORTING_FILES * 2) + 3,
63
354
  };
64
355
  const planId = `multica_deploy_plan_${digest(stableStringify({
65
356
  action, workspace: name, profile: readPlan.profile, inspectionId,
66
357
  desiredHash: workspace.desiredHash, deploymentHash: source.deploymentHash,
67
- expected: readPlan.expected, operations, writeBudget,
358
+ expected: readPlan.expected, operations, writeBudget, rebindRuntime, previousRuntimeId,
68
359
  blocking: [...new Set(blocking)].sort(),
69
360
  })).slice(0, 24)}`;
70
361
  return {
@@ -85,7 +376,9 @@ export function planMulticaDeployment(start, name, options = {}) {
85
376
  skills: expectedSkills,
86
377
  definitionHash: source.definitionHash,
87
378
  instructionsHash: source.instructionsHash,
379
+ ...(rebindRuntime ? { previousRuntimeId } : {}),
88
380
  },
381
+ rebindRuntime,
89
382
  operations,
90
383
  writeBudget,
91
384
  blocking: [...new Set(blocking)].sort(),
@@ -106,9 +399,12 @@ export function applyMulticaDeployment(start, name, options = {}, action = 'appl
106
399
  throw new Error('dta deploy 必须传 dry-run 返回的 --plan-id');
107
400
  const env = options.env || process.env;
108
401
  const cliVersion = options.cliVersion || '';
109
- 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 });
110
404
  if (options.planId !== plan.planId) {
111
- 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');
112
408
  }
113
409
  if (plan.blocking.length)
114
410
  throw new Error(`deploy 前置条件不完整: ${plan.blocking.join(', ')}`);
@@ -126,7 +422,12 @@ export function applyMulticaDeployment(start, name, options = {}, action = 'appl
126
422
  preflight.profile !== plan.profile ||
127
423
  preflight.expected.workspaceId !== plan.expected.workspaceId ||
128
424
  preflight.expected.runtimeId !== plan.expected.runtimeId) {
129
- 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)');
130
431
  }
131
432
  appendInspectionCalls(preflight, 'preflight', calls);
132
433
  const operationId = `multica_operation_${Date.now()}_${randomUUID().slice(0, 12)}`;
@@ -144,7 +445,7 @@ export function applyMulticaDeployment(start, name, options = {}, action = 'appl
144
445
  receipt = retireDeployment(info.root, workspace, source, plan, preflight, operation, command, env, timeoutMs, calls);
145
446
  }
146
447
  else {
147
- receipt = executeApply(info.root, workspace, source, plan, preflight, operation, command, env, timeoutMs, positiveTimeout(options.smokeTimeoutMs, 30_000, 60_000), Boolean(options.noWait), calls, rollback, temp);
448
+ receipt = executeApply(info.root, workspace, source, plan, preflight, operation, command, env, timeoutMs, positiveTimeout(options.smokeTimeoutMs, 30_000, 300_000), Boolean(options.noWait), calls, rollback, temp);
148
449
  }
149
450
  }
150
451
  catch (error) {
@@ -159,7 +460,7 @@ export function applyMulticaDeployment(start, name, options = {}, action = 'appl
159
460
  operation.calls = calls;
160
461
  operation.failures = [failure.category, ...rollbackReport.failures];
161
462
  operation.updatedAt = new Date().toISOString();
162
- 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);
163
464
  }
164
465
  finally {
165
466
  rmSync(temp, { recursive: true, force: true });
@@ -187,6 +488,7 @@ function executeApply(root, workspace, source, plan, preflight, operation, comma
187
488
  calls: [...calls],
188
489
  agent: { ...previous.agent, action: 'noop' },
189
490
  skills: previous.skills.map((skill) => ({ ...skill, action: 'noop' })),
491
+ failureDetails: Array.isArray(previous.failureDetails) ? previous.failureDetails : [],
190
492
  evidencePath: receiptRelativePath(workspace.name, receiptId),
191
493
  remoteWrite: false,
192
494
  sideEffect: 'multica-read',
@@ -235,9 +537,12 @@ function executeApply(root, workspace, source, plan, preflight, operation, comma
235
537
  throw new DeploymentFailure('readback.deployment-mismatch');
236
538
  }
237
539
  const observedHash = deploymentObservedHash(finalSnapshot, readback.inspectionId);
238
- 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,避免把一次正常部署误判成失败、把人推去改本体换模板。
239
543
  const status = smoke.status === 'passed'
240
- ? 'ready' : smoke.status === 'pending' ? 'verifying' : 'failed';
544
+ ? 'ready' : smoke.status === 'pending' ? 'verifying'
545
+ : smoke.classification === 'skills-not-visible' ? 'verifying' : 'failed';
241
546
  const receiptId = `multica_receipt_${digest(stableStringify({
242
547
  operationId: operation.operationId, planId: plan.planId, observedHash,
243
548
  smoke: { markerHash: smoke.markerHash, issueId: smoke.issueId, taskId: smoke.taskId },
@@ -274,6 +579,13 @@ function executeApply(root, workspace, source, plan, preflight, operation, comma
274
579
  smoke,
275
580
  rollback: emptyRollback(false),
276
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
+ } : {}),
277
589
  evidencePath: receiptRelativePath(workspace.name, receiptId),
278
590
  remoteRead: true,
279
591
  remoteWrite: calls.some((call) => call.kind === 'write'),
@@ -328,6 +640,7 @@ function retireDeployment(root, workspace, source, plan, preflight, operation, c
328
640
  smoke: emptySmoke('not-run', source.skills.map((item) => item.name)),
329
641
  rollback: emptyRollback(false),
330
642
  failures: [],
643
+ failureDetails: [],
331
644
  evidencePath: receiptRelativePath(workspace.name, receiptId),
332
645
  remoteRead: true,
333
646
  remoteWrite: calls.some((call) => call.kind === 'write'),
@@ -344,15 +657,9 @@ function loadDeploymentSource(root) {
344
657
  const definitionHash = sha256(definition);
345
658
  const skillRoots = [];
346
659
  for (const name of info.project.skills) {
347
- const ref = name === 'dingtalk-basic-behavior'
348
- ? `.agents/skills/${name}` : `skills/${name}`;
660
+ const ref = projectSkillRef(info.project, name);
349
661
  skillRoots.push({ name, root: safeProjectPath(root, ref, true) });
350
662
  }
351
- const packageRoot = resolvePackageRoot(import.meta.url);
352
- skillRoots.push({
353
- name: MULTICA_BOOT_SKILL,
354
- root: safeExternalSkillRoot(bundledSkillPath(packageRoot, MULTICA_BOOT_SKILL)),
355
- });
356
663
  if (skillRoots.length > MAX_DEPLOY_SKILLS) {
357
664
  throw new Error(`Multica deploy 最多支持 ${MAX_DEPLOY_SKILLS} 个受管 Skills`);
358
665
  }
@@ -361,26 +668,17 @@ function loadDeploymentSource(root) {
361
668
  if (new Set(skills.map((item) => item.name)).size !== skills.length) {
362
669
  throw new Error('Multica deploy required Skills 不能重名');
363
670
  }
364
- const required = skills.map((item) => ({ name: item.name, sha256: item.hash }));
365
- const deploymentSourceHash = digest(stableStringify({ definitionHash, required }));
366
- const instructions = [
367
- '<!-- DTA Multica Boot: managed; do not edit remotely -->',
368
- `definition_sha256=${definitionHash}`,
369
- `deployment_sha256=${deploymentSourceHash}`,
370
- `required_skills=${JSON.stringify(required)}`,
371
- 'Before every task, use dingtalk-agent-boot-multica and follow its startup order.',
372
- 'Never create a robot, webhook, autopilot, schedule, or other Trigger as part of deployment.',
373
- '<!-- /DTA Multica Boot -->',
374
- '',
375
- definition.trimEnd(),
376
- '',
377
- ].join('\n');
671
+ // Agent instructions are the human-authored Definition, byte for byte. Deployment
672
+ // hashes and the managed Skill set belong to the control plane and its Receipt;
673
+ // injecting them into the System Prompt makes the role unreadable and creates a
674
+ // second runtime protocol beside the Host's native Skill assignment.
675
+ const instructions = definition;
378
676
  const instructionsHash = sha256(instructions);
379
677
  const deploymentHash = digest(stableStringify({
380
678
  definitionHash, instructionsHash,
381
679
  skills: skills.map((item) => ({ name: item.name, hash: item.hash })),
382
680
  }));
383
- return { definition, definitionHash, instructions, instructionsHash, skills, deploymentHash };
681
+ return { definitionHash, instructions, instructionsHash, skills, deploymentHash };
384
682
  }
385
683
  function loadSkillBundle(name, root) {
386
684
  const actual = realpathSync(root);
@@ -557,73 +855,277 @@ function upsertSkillFile(root, plan, skillId, path, contentFile, command, env, t
557
855
  '--content-file', contentFile, '--output', 'json',
558
856
  ]), `skill.file.upsert:${path}`, 'write', root, env, timeoutMs, calls);
559
857
  }
560
- 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) {
561
859
  const marker = `DTA-MULTICA-LOAD-${randomUUID()}`;
562
860
  const requiredSkills = source.skills.map((item) => item.name).sort();
861
+ const expectedResponse = JSON.stringify({
862
+ schema: 'dta-multica-load-smoke@1', marker, loaded: requiredSkills,
863
+ });
563
864
  const issue = jsonObject(runProvider(command, scopedArgs(plan, [
564
865
  'issue', 'create', '--title', `[DTA load smoke] ${marker}`,
565
866
  '--description', [
566
867
  'DTA_MULTICA_LOAD_SMOKE@1', `marker=${marker}`,
567
- 'Use only the trusted DTA Multica Boot required_skills list.',
568
- 'Do not call DWS, Shell, network, or write tools.',
868
+ `required_skills=${JSON.stringify(requiredSkills)}`,
869
+ 'This is a deployment control-plane verification task.',
870
+ 'Load every required skill above through the Host skill mechanism, in the listed order.',
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.',
872
+ `Post exactly this JSON as an Issue comment (and nothing else): ${expectedResponse}`,
569
873
  ].join('\n'),
570
874
  '--assignee-id', agentId, '--output', 'json',
571
875
  ]), 'smoke.issue.create', 'write', root, env, timeoutMs, calls).stdout, 'smoke issue');
572
876
  const issueId = resourceId(issue.id, 'smoke issue.id');
877
+ const read = () => readSmoke(root, plan, marker, issueId, agentId, requiredSkills, runtimeProvider, command, env, timeoutMs, calls);
878
+ let smoke = read();
879
+ // Multica persists the Issue before attempting the initial agent enqueue. If
880
+ // that enqueue loses a transient readiness race, the API still returns a
881
+ // valid assigned todo Issue but issue runs remains empty forever. Recover
882
+ // exactly that observable state once through an explicit ID-bound mention on
883
+ // the same Issue. A visible task — queued, running or terminal — is never
884
+ // duplicated. The mention route is used instead of issue rerun because it is
885
+ // the platform's normal first-run trigger and does not require a prior task.
886
+ if (smoke.status === 'pending' && !smoke.taskId) {
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
+ }
899
+ }
573
900
  if (noWait)
574
- return readSmoke(root, plan, marker, issueId, agentId, requiredSkills, command, env, timeoutMs, calls);
901
+ return smoke;
575
902
  const started = Date.now();
576
- let smoke = readSmoke(root, plan, marker, issueId, agentId, requiredSkills, command, env, timeoutMs, calls);
577
- while (smoke.status === 'pending' && Date.now() - started < smokeTimeoutMs) {
578
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
579
- 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();
946
+ }
947
+ if (smoke.status === 'pending' && !smoke.taskDiagnostic) {
948
+ const detail = smoke.taskId
949
+ ? `task ${smoke.taskId} 在 ${smokeTimeoutMs}ms 内未进入终态(当前 ${smoke.taskStatus || 'unknown'})`
950
+ : `Issue ${issueId} 在 ${smokeTimeoutMs}ms 内未创建关联 task`;
951
+ return { ...smoke, taskDiagnostic: detail };
580
952
  }
581
953
  return smoke;
582
954
  }
583
- 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
+ };
584
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');
585
975
  const candidates = runs.filter((item) => !optionalString(item.agent_id) ||
586
976
  optionalString(item.agent_id) === agentId);
587
977
  const run = candidates.sort((a, b) => optionalString(b.created_at).localeCompare(optionalString(a.created_at)))[0];
588
978
  if (!run)
589
- return {
590
- ...emptySmoke('pending', requiredSkills), marker, markerHash: sha256(marker), issueId,
591
- };
979
+ return base;
592
980
  const taskId = resourceId(run.id, 'smoke task.id');
593
981
  const taskStatus = optionalString(run.status);
594
982
  if (!['completed', 'failed', 'cancelled'].includes(taskStatus))
595
983
  return {
596
- ...emptySmoke('pending', requiredSkills), marker, markerHash: sha256(marker), issueId, taskId, taskStatus,
984
+ ...base, taskId, taskStatus,
597
985
  };
598
986
  const messages = jsonArray(runProvider(command, scopedArgs(plan, [
599
987
  'issue', 'run-messages', taskId, '--issue', issueId, '--output', 'json',
600
988
  ]), 'smoke.issue.messages', 'read', root, env, timeoutMs, calls).stdout, 'smoke messages');
601
989
  const toolUses = messages.filter((item) => optionalString(item.type) === 'tool_use');
602
- const skillToolUses = toolUses.filter((item) => optionalString(item.tool) === 'skill');
603
- const skillNames = skillToolUses.map((item) => smokeToolInput(item).name)
604
- .filter((item) => typeof item === 'string' && Boolean(item)).sort();
605
- 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
+ }
606
1051
  const texts = messages.filter((item) => optionalString(item.type) === 'text')
607
1052
  .map((item) => optionalString(item.content)).filter(Boolean);
608
1053
  const finalText = texts.at(-1) || '';
609
- const matchesResponse = (value) => smokeResponseMatches(value, marker, requiredSkills);
610
- const replyWrites = toolUses.filter((item) => optionalString(item.tool) === 'write')
611
- .map((item) => smokeToolInput(item)).filter((input) => optionalString(input.filePath).endsWith('/reply.md'))
612
- .map((input) => optionalString(input.content)).filter(Boolean);
613
- const responseEvidence = [...texts, ...replyWrites].find(matchesResponse) || '';
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]
1073
+ .find(matchesResponse) || '';
614
1074
  const responsePassed = Boolean(responseEvidence);
615
- const toolTracePassed = toolUses.every((item) => allowedSmokeToolUse(item, issueId, marker, requiredSkills)) && stableStringify(skillNames) === stableStringify(requiredSkills);
1075
+ const toolTracePassed = skillEvidencePassed && !forbiddenTools.length && !forbiddenCommands;
616
1076
  const failures = [];
1077
+ const failureDetails = [];
617
1078
  if (taskStatus !== 'completed')
618
1079
  failures.push(`smoke.task.${taskStatus || 'unknown'}`);
619
- if (!toolTracePassed)
620
- failures.push('smoke.skill-tool-trace');
621
- 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) {
622
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
+ }
623
1123
  return {
624
- status: failures.length ? 'failed' : 'passed',
625
- marker, markerHash: sha256(marker), issueId, taskId, taskStatus, requiredSkills, loadedSkills,
1124
+ status: failures.length ? 'failed' : 'passed', marker, markerHash: sha256(marker),
1125
+ issueId, taskId, taskStatus, taskDiagnostic: remoteTaskDiagnostic(run), requiredSkills, loadedSkills,
626
1126
  toolTracePassed, responsePassed, responseHash: sha256(responseEvidence || finalText), failures,
1127
+ runtimeProvider: provider, vocabularyIds, toolNames, unrecognizedTools, classification,
1128
+ failureDetails,
627
1129
  };
628
1130
  }
629
1131
  function smokeToolInput(item) {
@@ -641,48 +1143,25 @@ function smokeResponseMatches(value, marker, requiredSkills) {
641
1143
  return false;
642
1144
  }
643
1145
  }
644
- function allowedSmokeToolUse(item, issueId, marker, requiredSkills) {
645
- const tool = optionalString(item.tool);
646
- if (tool === 'skill')
647
- return true;
648
- const input = smokeToolInput(item);
649
- if (tool === 'write') {
650
- return safeSmokeReplyPath(optionalString(input.filePath)) &&
651
- smokeResponseMatches(optionalString(input.content), marker, requiredSkills);
652
- }
653
- if (tool !== 'bash')
654
- return false;
655
- const command = optionalString(input.command);
656
- if (new Set([
657
- `multica issue get ${issueId} --output json`,
658
- `multica issue metadata list ${issueId} --output json`,
659
- `multica issue comment list ${issueId} --recent 10 --output json`,
660
- `multica issue status ${issueId} in_progress`,
661
- `multica issue status ${issueId} in_review`,
662
- ]).has(command))
663
- return true;
664
- const commentPrefix = `multica issue comment add ${issueId} --content-file `;
665
- if (command.startsWith(commentPrefix)) {
666
- return safeSmokeReplyPath(command.slice(commentPrefix.length));
667
- }
668
- const cleanupFirstPrefix = 'rm ';
669
- const cleanupFirstSuffix = ` && multica issue status ${issueId} in_review`;
670
- if (command.startsWith(cleanupFirstPrefix) && command.endsWith(cleanupFirstSuffix)) {
671
- return safeSmokeReplyPath(command.slice(cleanupFirstPrefix.length, command.length - cleanupFirstSuffix.length));
672
- }
673
- const statusFirstPrefix = `multica issue status ${issueId} in_review && rm `;
674
- if (command.startsWith(statusFirstPrefix)) {
675
- return safeSmokeReplyPath(command.slice(statusFirstPrefix.length));
676
- }
677
- if (command.startsWith(cleanupFirstPrefix)) {
678
- return safeSmokeReplyPath(command.slice(cleanupFirstPrefix.length));
679
- }
680
- return false;
1146
+ function safeCombinedSmokeResponse(command, issueId, marker, requiredSkills) {
1147
+ const response = JSON.stringify({ schema: 'dta-multica-load-smoke@1', marker, loaded: requiredSkills });
1148
+ return command === [
1149
+ "cat > ./reply.md << 'REPLY_EOF'",
1150
+ response,
1151
+ 'REPLY_EOF',
1152
+ `multica issue comment add ${issueId} --content-file ./reply.md && rm ./reply.md`,
1153
+ ].join('\n');
681
1154
  }
682
- function safeSmokeReplyPath(value) {
683
- if (value === './reply.md')
684
- return true;
685
- if (!value.startsWith('/') || !value.endsWith('/reply.md'))
1155
+ function safeSmokeResponsePath(value) {
1156
+ const names = new Set(['reply.md', 'result.md', 'result.json']);
1157
+ if (value.startsWith('./'))
1158
+ return names.has(value.slice(2));
1159
+ // 裸文件名与 `./` 前缀等价:Agent 在 workdir 里写绝对路径、随后用裸名引用同一个文件是
1160
+ // 平台的常见写法。只放行精确 basename —— `foo/result.md` 与 `../result.md` 仍然落到
1161
+ // 下面的绝对路径分支并被拒绝,白名单没有变宽。
1162
+ if (!value.startsWith('/'))
1163
+ return names.has(value);
1164
+ if (!names.has(value.split('/').at(-1) || ''))
686
1165
  return false;
687
1166
  return value.split('/').every((part) => !part ||
688
1167
  (part !== '.' && part !== '..' && /^[A-Za-z0-9._-]+$/.test(part)));
@@ -800,7 +1279,7 @@ function verifyRollback(root, plan, command, env, timeoutMs, calls, rollback, re
800
1279
  });
801
1280
  }
802
1281
  }
803
- 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) {
804
1283
  const receiptId = `multica_receipt_${digest(stableStringify({
805
1284
  operationId: operation.operationId, status, failure,
806
1285
  })).slice(0, 24)}`;
@@ -838,6 +1317,7 @@ function failureReceipt(root, workspace, source, plan, preflight, operation, cal
838
1317
  smoke: emptySmoke('not-run', source.skills.map((item) => item.name)),
839
1318
  rollback,
840
1319
  failures: [failure, ...rollback.failures],
1320
+ failureDetails: failureDetail ? [failureDetail] : [],
841
1321
  evidencePath: receiptRelativePath(workspace.name, receiptId),
842
1322
  remoteRead: true,
843
1323
  remoteWrite: calls.some((call) => call.kind === 'write' || call.kind === 'rollback'),
@@ -857,6 +1337,7 @@ export function statusMulticaDeployment(start, name, options = {}) {
857
1337
  workspace: name, provider: 'multica', available: false, operationId: '', receiptId: '',
858
1338
  status: 'missing', passed: false, providerReady: false,
859
1339
  operationIntegrity: false, receiptIntegrity: false, reconciled: false,
1340
+ receiptFrozen: false, smokeStale: false, nextAction: '',
860
1341
  failures: ['deployment.missing'], receipt: null,
861
1342
  remoteRead: false, remoteWrite: false, triggerWrite: false,
862
1343
  sideEffect: false, dingtalkSideEffect: false,
@@ -864,6 +1345,7 @@ export function statusMulticaDeployment(start, name, options = {}) {
864
1345
  let { operation, receipt } = loaded;
865
1346
  let reconciled = false;
866
1347
  let remoteRead = false;
1348
+ let remoteWrite = false;
867
1349
  if (options.execute) {
868
1350
  if (!options.yes)
869
1351
  throw new Error('deploy --status 远端 reconcile 必须同时传 --execute --yes');
@@ -872,12 +1354,41 @@ export function statusMulticaDeployment(start, name, options = {}) {
872
1354
  const timeoutMs = positiveTimeout(options.timeoutMs, 30_000, 300_000);
873
1355
  if (receipt.status === 'verifying' && receipt.smoke.issueId) {
874
1356
  remoteRead = true;
875
- const smokeReadback = readSmokeFromReceipt(info.root, receipt, command, env, timeoutMs);
876
- const smoke = smokeReadback.smoke;
877
- receipt = { ...receipt, calls: smokeReadback.calls, smoke, status: smoke.status === 'passed' ? 'ready'
878
- : 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,
879
1389
  passed: smoke.status === 'passed', providerReady: smoke.status === 'passed',
880
- failures: [...smoke.failures] };
1390
+ failures: [...smoke.failures],
1391
+ failureDetails: [...smoke.failureDetails] };
881
1392
  reconciled = true;
882
1393
  persistReceiptAndState(info.root, showDevelopmentWorkspace(info.root, name), operation, receipt);
883
1394
  }
@@ -911,6 +1422,7 @@ export function statusMulticaDeployment(start, name, options = {}) {
911
1422
  }
912
1423
  operation = loadOperationAndReceipt(info.root, name, operation.operationId)?.operation || operation;
913
1424
  }
1425
+ const receiptFrozen = !options.execute;
914
1426
  return {
915
1427
  $schema: 'dingtalk-agent/multica-deployment-status@1',
916
1428
  workspace: name,
@@ -924,12 +1436,17 @@ export function statusMulticaDeployment(start, name, options = {}) {
924
1436
  operationIntegrity: true,
925
1437
  receiptIntegrity: true,
926
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
+ : '',
927
1444
  failures: [...receipt.failures],
928
1445
  receipt,
929
1446
  remoteRead,
930
- remoteWrite: false,
1447
+ remoteWrite,
931
1448
  triggerWrite: false,
932
- sideEffect: remoteRead ? 'multica-read' : false,
1449
+ sideEffect: remoteWrite ? 'multica-read-write' : remoteRead ? 'multica-read' : false,
933
1450
  dingtalkSideEffect: false,
934
1451
  };
935
1452
  }
@@ -1055,7 +1572,7 @@ function readSmokeFromReceipt(root, receipt, command, env, timeoutMs) {
1055
1572
  failures: ['smoke.marker-not-recoverable'],
1056
1573
  } };
1057
1574
  }
1058
- 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);
1059
1576
  return { smoke, calls };
1060
1577
  }
1061
1578
  function persistReceiptAndState(root, workspace, operation, receipt) {
@@ -1225,7 +1742,7 @@ function runProvider(command, argv, id, kind, cwd, env, timeoutMs, calls) {
1225
1742
  throw new DeploymentFailure('budget.rollback-exhausted');
1226
1743
  }
1227
1744
  const run = spawnSync(command, argv, {
1228
- cwd, env, encoding: 'utf8', timeout: timeoutMs,
1745
+ cwd, env: sanitizeProviderEnv(env).env, encoding: 'utf8', timeout: timeoutMs,
1229
1746
  maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
1230
1747
  });
1231
1748
  const stdout = String(run.stdout || '');
@@ -1237,8 +1754,10 @@ function runProvider(command, argv, id, kind, cwd, env, timeoutMs, calls) {
1237
1754
  id, kind, argv: redactArgv(argv), exitCode,
1238
1755
  stdoutHash: sha256(stdout), stderrHash: sha256(stderr), passed, ambiguous,
1239
1756
  });
1240
- if (!passed)
1757
+ if (!passed) {
1758
+ emitProviderDiagnostic(id, exitCode, stderr || stdout);
1241
1759
  throw new DeploymentFailure(`command.${id}`, ambiguous);
1760
+ }
1242
1761
  return { stdout, stderr };
1243
1762
  }
1244
1763
  function scopedArgs(plan, args) {
@@ -1356,9 +1875,11 @@ function assertReportRedacted(value) {
1356
1875
  }
1357
1876
  function emptySmoke(status, requiredSkills) {
1358
1877
  return {
1359
- status, marker: '', markerHash: '', issueId: '', taskId: '', taskStatus: '',
1878
+ status, marker: '', markerHash: '', issueId: '', taskId: '', taskStatus: '', taskDiagnostic: '',
1360
1879
  requiredSkills: [...requiredSkills].sort(), loadedSkills: [],
1361
1880
  toolTracePassed: false, responsePassed: false, responseHash: '', failures: [],
1881
+ runtimeProvider: '', vocabularyIds: [], toolNames: [], unrecognizedTools: [],
1882
+ classification: '', failureDetails: [],
1362
1883
  };
1363
1884
  }
1364
1885
  function emptyRollback(attempted) {
@@ -1432,6 +1953,58 @@ function stringField(value, label) {
1432
1953
  function optionalString(value) {
1433
1954
  return typeof value === 'string' ? value : '';
1434
1955
  }
1956
+ function safeRemoteOutput(value, maxBytes) {
1957
+ const redacted = value
1958
+ .replace(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, '[redacted-private-key]')
1959
+ .replace(/\bBearer\s+[A-Za-z0-9._~+\/-]{20,}/gi, 'Bearer [redacted]')
1960
+ .replace(/\b(?:mul_|mcn_|sk-)[A-Za-z0-9._~+\/-]{12,}/gi, '[redacted-token]');
1961
+ const bytes = Buffer.from(redacted, 'utf8');
1962
+ return bytes.length <= maxBytes ? redacted : `${bytes.subarray(0, maxBytes).toString('utf8')}…`;
1963
+ }
1964
+ function uniqueCalls(calls) {
1965
+ const seen = new Set();
1966
+ return calls.filter((call) => {
1967
+ const key = stableStringify({
1968
+ id: call.id,
1969
+ kind: call.kind,
1970
+ argv: call.argv,
1971
+ exitCode: call.exitCode,
1972
+ stdoutHash: call.stdoutHash,
1973
+ stderrHash: call.stderrHash,
1974
+ passed: call.passed,
1975
+ ambiguous: call.ambiguous,
1976
+ });
1977
+ if (seen.has(key))
1978
+ return false;
1979
+ seen.add(key);
1980
+ return true;
1981
+ });
1982
+ }
1983
+ function remoteTaskDiagnostic(task) {
1984
+ if (!task)
1985
+ return '';
1986
+ const values = [];
1987
+ const add = (label, value) => {
1988
+ if (typeof value === 'string' && value.trim())
1989
+ values.push(`${label}=${value.trim()}`);
1990
+ else if (typeof value === 'number' || typeof value === 'boolean')
1991
+ values.push(`${label}=${value}`);
1992
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
1993
+ const nested = value;
1994
+ for (const key of ['code', 'category', 'message', 'reason', 'detail']) {
1995
+ if (nested[key] !== undefined)
1996
+ add(`${label}.${key}`, nested[key]);
1997
+ }
1998
+ }
1999
+ };
2000
+ for (const key of [
2001
+ 'error', 'error_message', 'failure_reason', 'status_message', 'reason', 'message', 'detail',
2002
+ ]) {
2003
+ if (task[key] !== undefined)
2004
+ add(key, task[key]);
2005
+ }
2006
+ return safeRemoteOutput(values.join('; '), 2_000);
2007
+ }
1435
2008
  function resourceId(value, label) {
1436
2009
  const item = stringField(value, label);
1437
2010
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$/.test(item) ||
@@ -1454,12 +2027,6 @@ function requireMulticaWorkspace(start, name, cliVersion = '') {
1454
2027
  }
1455
2028
  return workspace;
1456
2029
  }
1457
- function safeExternalSkillRoot(path) {
1458
- if (!existsSync(path) || lstatSync(path).isSymbolicLink()) {
1459
- throw new Error(`Canonical Multica Boot Skill 不存在或是符号链接: ${path}`);
1460
- }
1461
- return realpathSync(path);
1462
- }
1463
2030
  function safeProjectPath(root, ref, mustExist) {
1464
2031
  const rootReal = realpathSync(resolve(root));
1465
2032
  const candidate = resolve(rootReal, ref);