agentworkshop 0.7.46 → 0.7.48

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 (36) hide show
  1. package/.output/.AgentWorkShop/aml-python/__pycache__/amlkit.cpython-312.pyc +0 -0
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/_nuxt/builds/latest.json +1 -1
  4. package/.output/public/_nuxt/builds/meta/6f524f3b-53e1-4df9-84e6-84d8058b9f16.json +1 -0
  5. package/.output/server/chunks/_/nitro.mjs +955 -858
  6. package/.output/server/package.json +1 -1
  7. package/README-zh.md +7 -2
  8. package/README.md +7 -2
  9. package/config.yml +1 -1
  10. package/docs/aml.md +2 -0
  11. package/docs/cli.en.md +2 -2
  12. package/docs/cli.md +2 -2
  13. package/docs/full-test-plan.md +1 -1
  14. package/docs/plugins.en.md +1 -1
  15. package/docs/plugins.md +1 -1
  16. package/docs/sdk.en.md +1 -1
  17. package/docs/sdk.md +1 -1
  18. package/package.json +1 -1
  19. package/scripts/_aw-packaged-start.cjs +22 -0
  20. package/scripts/_aw0746-stage12.mjs +94 -0
  21. package/scripts/_aw0746-stage34.mjs +136 -0
  22. package/scripts/_aw0746-stage5.mjs +144 -0
  23. package/scripts/_aw0746-stage6-aml.mjs +177 -0
  24. package/scripts/_aw0746-stage6b-train-sweep.mjs +145 -0
  25. package/scripts/_aw0746-stage6c-jobsmoke.mjs +69 -0
  26. package/scripts/_aw0746-stage7-twin.mjs +267 -0
  27. package/scripts/_aw0746-train-strong.py +142 -0
  28. package/scripts/_aw0747-readme-rows.mjs +32 -0
  29. package/scripts/_aw0748-failclosed-probe.mjs +90 -0
  30. package/scripts/_plc-sim-detached.cjs +22 -0
  31. package/server/services/workshop/agents/industrial/twin-tools.ts +85 -10
  32. package/server/services/workshop/aml/job-orchestrator/start-job.ts +140 -124
  33. package/server/services/workshop/aml/python/__pycache__/amlkit.cpython-312.pyc +0 -0
  34. package/server/services/workshop/aml/twin/physics-runtime.ts +16 -1
  35. package/server/services/workshop/aml/twin/repo.ts +9 -4
  36. package/.output/public/_nuxt/builds/meta/7827fe9f-b4c2-461f-b1fb-c0c7da7d66b8.json +0 -1
@@ -10,7 +10,9 @@ import { defaultInjectionScene, InjectionGreyboxProvider, smallStepCandidates }
10
10
  import { createTwinSnapshot } from '../../aml/twin/snapshot-service'
11
11
  import { evaluateHybridGates, DEFAULT_ACCEPTANCE_PROFILE } from '../../aml/twin/acceptance'
12
12
  import { issueRecommendationCertificate, runVirtualTrial } from '../../aml/twin/trial-service'
13
- import { sha256 } from '../../aml/twin/contracts'
13
+ import { parseSceneContract, sha256, type SceneContract } from '../../aml/twin/contracts'
14
+ import { getDaqController } from '../../daq/daq-controller'
15
+ import { getAgentNodeBindingRepo } from '../node-bindings.repo'
14
16
  import type { HostToolResult } from '../host-tool-bridge/types'
15
17
  import { recordOps } from '../../ops/ops'
16
18
  import { createTwinRepo } from '../../aml/twin/repo'
@@ -46,17 +48,46 @@ async function persistTwinArtifact(kind: string, id: string, payload: unknown):
46
48
  }
47
49
 
48
50
  export async function toolTwinSceneRead(_agentId: string, args: Record<string, unknown>): Promise<HostToolResult> {
49
- const scene = defaultInjectionScene('twin-scene-tool')
51
+ const defaultScene = defaultInjectionScene('twin-scene-tool')
52
+ const supplied = jsonArg<Record<string, unknown>>(args, 'scene_json')
53
+ const scene = supplied ? parseSceneContract(supplied) : defaultScene
50
54
  const sceneId = String(args.scene_id ?? scene.sceneId)
51
- if (sceneId !== scene.sceneId) return fail(`当前 MVP 仅内置可执行场景 ${scene.sceneId};未知 scene_id=${sceneId} 请先注册 PhysicsModelProvider。`)
52
- return ok(`场景契约 ${scene.sceneId}@${scene.sceneVersion}\n${JSON.stringify(scene, null, 2)}\n\n控制策略:recommendation-only;模型门禁未通过时只能 safe_small_step,禁止直接 DCW。`)
55
+ if (sceneId !== scene.sceneId) return fail(`场景契约未提供: scene_id=${sceneId};请通过 scene_json 注册该场景,或先注册 PhysicsModelProvider。`)
56
+ return ok(`场景契约 ${scene.sceneId}@${scene.sceneVersion}\n${JSON.stringify(scene, null, 2)}\n\n控制策略:recommendation-only;模型门禁未通过时只能 safe_small_step,禁止直接 DCW。\n快照前置:scene 的 observations/states 必须带 nodeId,且与 Agent 已绑定的真实 DAQ 节点一致;内置默认场景不带 nodeId,直接调用只会在 TwinSnapshot 里得到 fresh=false 并在 VirtualTrial 抛 SNAPSHOT_STALE —— 请用 scene_json 注入带 nodeId 的场景契约。`)
57
+ }
58
+
59
+ async function autoDaqSamples(agentId: string, scene: SceneContract, nowMs: number, freshnessMaxMs: number): Promise<Array<{ nodeId: string, at: number, value: number, sequence?: string }>> {
60
+ const required = [...scene.observations, ...scene.states]
61
+ .map(item => item.nodeId)
62
+ .filter((nodeId): nodeId is string => Boolean(nodeId))
63
+ const daqBindings = new Set(getAgentNodeBindingRepo().byAgent(agentId).filter(binding => binding.kind === 'daq').map(binding => binding.nodeId))
64
+ const missingBindings = required.filter(nodeId => !daqBindings.has(nodeId))
65
+ if (missingBindings.length > 0) throw new Error(`AUTO_DAQ_UNBOUND:${missingBindings.join(',')}`)
66
+ const samples: Array<{ nodeId: string, at: number, value: number, sequence?: string }> = []
67
+ for (const nodeId of required) {
68
+ const points = await getDaqController().samples(nodeId, { fromMs: nowMs - freshnessMaxMs, toMs: nowMs, bucketMs: 1000, limit: 64 }) as Array<{ at?: number, value?: number, avg?: number }>
69
+ const latest = points
70
+ .map(point => ({ at: Number(point.at), value: Number(point.value ?? point.avg) }))
71
+ .filter(point => Number.isFinite(point.at) && Number.isFinite(point.value))
72
+ .sort((a, b) => a.at - b.at)
73
+ .at(-1)
74
+ if (!latest) throw new Error(`AUTO_DAQ_SAMPLE_MISSING:${nodeId}`)
75
+ samples.push({ nodeId, at: latest.at, value: latest.value, sequence: `daq:${nodeId}:${latest.at}` })
76
+ }
77
+ return samples
53
78
  }
54
79
 
55
80
  export async function toolTwinSnapshotCreate(agentId: string, args: Record<string, unknown>): Promise<HostToolResult> {
56
81
  try {
57
- const scene = (jsonArg(args, 'scene_json') ?? defaultInjectionScene('twin-snapshot-tool')) as ReturnType<typeof defaultInjectionScene>
82
+ const scene = parseSceneContract(jsonArg(args, 'scene_json') ?? defaultInjectionScene('twin-snapshot-tool'))
58
83
  const twinRepo = createTwinRepo(getAmlRuntime().db)
59
84
  twinRepo.upsertScene(scene, agentId)
85
+ const nowMs = Number(args.now_ms) || Date.now()
86
+ const freshnessMaxMs = Number(args.freshness_max_ms) || 60_000
87
+ const suppliedSamples = jsonArg<Array<{ nodeId: string, at: number, value: number, sequence?: string }>>(args, 'samples', []) ?? []
88
+ const samples = args.auto_daq === true || suppliedSamples.length === 0
89
+ ? await autoDaqSamples(agentId, scene, nowMs, freshnessMaxMs)
90
+ : suppliedSamples
60
91
  const snapshot = createTwinSnapshot({
61
92
  scene,
62
93
  channelId: String(args.channel_id ?? ''),
@@ -65,14 +96,24 @@ export async function toolTwinSnapshotCreate(agentId: string, args: Record<strin
65
96
  controls: jsonArg<Record<string, number>>(args, 'controls', {}) ?? {},
66
97
  states: jsonArg<Record<string, number>>(args, 'states', {}) ?? {},
67
98
  disturbances: jsonArg<Record<string, number>>(args, 'disturbances', {}) ?? {},
68
- samples: jsonArg<Array<{ nodeId: string, at: number, value: number, sequence?: string }>>(args, 'samples', []) ?? [],
69
- nowMs: Number(args.now_ms) || Date.now(),
70
- freshnessMaxMs: Number(args.freshness_max_ms) || 60_000,
99
+ samples,
100
+ nowMs,
101
+ freshnessMaxMs,
71
102
  })
72
103
  twinRepo.insertSnapshot(snapshot)
73
104
  const path = await persistTwinArtifact('snapshots', snapshot.snapshotId, snapshot)
74
105
  recordOps({ actor: agentId, actorName: agentBadgeLabel(agentId), actorKind: 'agent', action: 'aml.twin.snapshot_create', kind: 'aml' as 'system', summary: `创建 TwinSnapshot ${snapshot.snapshotId}`, targetKind: 'aml_twin_snapshot', targetId: snapshot.snapshotId, lineId: scene.lineId, productId: scene.productId, recipeId: scene.recipeId })
75
- return ok(`TwinSnapshot 已创建\n snapshot_id: ${snapshot.snapshotId}\n hash: ${snapshot.snapshotHash}\n fresh: ${snapshot.dataQuality.fresh}\n completeness: ${snapshot.dataQuality.completeness}\n artifact: ${path}`)
106
+ const quality = snapshot.dataQuality
107
+ // 全部必需节点都没有样本(watermark=0)时不是"数据不新鲜",而是场景映射/绑定配置错了:
108
+ // 旧行为只回 fresh=false,调用方要到 VirtualTrial 才看到 SNAPSHOT_STALE(实测踩过)。
109
+ const requiredCount = [...scene.observations, ...scene.states].filter(v => v.nodeId).length
110
+ if (snapshot.daqWatermark <= 0) {
111
+ const missing = requiredCount === 0
112
+ ? '场景 observations/states 没有任何 nodeId(内置默认场景即如此)'
113
+ : `以下节点没有可用样本: ${quality.staleNodeIds.join(', ')}`
114
+ return fail(`TwinSnapshot 创建失败:无可用 DAQ 样本(watermark=0)。${missing}。请用 scene_json 把场景观测/状态映射到已绑定的真实 DAQ 节点,并确认产线在跑。\n snapshot_id: ${snapshot.snapshotId}\n artifact: ${path}`)
115
+ }
116
+ return ok(`TwinSnapshot 已创建\n source: ${args.auto_daq === true || suppliedSamples.length === 0 ? 'bound DAQ' : 'caller samples (legacy/test)'}\n snapshot_id: ${snapshot.snapshotId}\n hash: ${snapshot.snapshotHash}\n fresh: ${quality.fresh}\n completeness: ${quality.completeness}\n stale_nodes: ${quality.staleNodeIds.length ? quality.staleNodeIds.join(', ') : '(none)'}\n artifact: ${path}`)
76
117
  }
77
118
  catch (err) {
78
119
  return fail(`TwinSnapshot 创建失败:${err instanceof Error ? err.message : String(err)}`)
@@ -84,6 +125,13 @@ export async function toolTwinTrialRun(agentId: string, args: Record<string, unk
84
125
  const scene = (jsonArg(args, 'scene_json') ?? defaultInjectionScene('twin-trial-tool')) as ReturnType<typeof defaultInjectionScene>
85
126
  const snapshot = jsonArg<Record<string, unknown>>(args, 'snapshot_json') as never
86
127
  if (!snapshot) return fail('snapshot_json 必填;必须使用当前 DAQ 生成的 TwinSnapshot,禁止 Agent 伪造执行状态。')
128
+ // 形状校验:传半截 JSON / 工具结果信封 / 纯字符串时旧行为是原生 TypeError
129
+ // (Cannot read properties of undefined (reading 'fresh')),调用方看不出该怎么修。
130
+ const snap = snapshot as { dataQuality?: { fresh?: boolean }, snapshotHash?: string, snapshotId?: string }
131
+ if (!snap.dataQuality || typeof snap.dataQuality.fresh !== 'boolean' || !snap.snapshotId) {
132
+ return fail('snapshot_json 不是 TwinSnapshot 工件:缺少 snapshotId/dataQuality。请先调用 twin_snapshot_create,并把其 artifact 文件的 JSON 原文整段回传(不要传工具结果文本或截断片段)。')
133
+ }
134
+ if (!snap.snapshotHash) return fail('snapshot_json 缺少 snapshotHash(快照被篡改或截断),拒绝执行 VirtualTrial。')
87
135
  const provider = new InjectionGreyboxProvider()
88
136
  const baseline = jsonArg<Record<string, number>>(args, 'baseline_controls', {}) ?? {}
89
137
  const candidate = jsonArg<Array<Record<string, number>>>(args, 'candidate_controls', []) ?? []
@@ -147,7 +195,34 @@ export async function toolTwinGateEvaluate(_agentId: string, args: Record<string
147
195
  const result = evaluateHybridGates({
148
196
  rows: Number(args.rows ?? 0), runs: Number(args.runs ?? 0), oneStepTestNrmse: Number(args.one_step_nrmse ?? 1), rolloutTestNrmse: Number(args.rollout_nrmse ?? 1), valTestGap: Number(args.val_test_gap ?? 1), calibrationRows: Number(args.calibration_rows ?? 0), calibrationCoverage: Number(args.coverage ?? 0), candidateTrials: jsonArg(args, 'candidate_trials', []) as never, physicsSolverFailureRate: Number(args.physics_failure_rate ?? 1),
149
197
  }, DEFAULT_ACCEPTANCE_PROFILE)
150
- return ok(JSON.stringify(result, null, 2))
198
+ const modelId = String(args.model_id ?? '').trim()
199
+ let modelUpdate = ''
200
+ if (modelId) {
201
+ const rt = getAmlRuntime()
202
+ const model = rt.repo.model.get(modelId)
203
+ if (model) {
204
+ let metrics: Record<string, unknown> = {}
205
+ try {
206
+ metrics = JSON.parse(model.metricsJson || '{}') as Record<string, unknown>
207
+ }
208
+ catch {
209
+ metrics = {}
210
+ }
211
+ const twinEligibility = {
212
+ gatePassed: result.passed,
213
+ recommendationEligible: result.passed,
214
+ uqPassed: result.checks.filter(c => c.id === 'G7_UQ_OOD').every(c => c.passed),
215
+ oodPassed: result.checks.filter(c => c.id === 'G7_UQ_OOD').every(c => c.passed),
216
+ physicsPassed: result.checks.filter(c => c.id === 'G5_PHYSICS').every(c => c.passed),
217
+ evaluatedAt: new Date().toISOString(),
218
+ }
219
+ metrics.twinEligibility = twinEligibility
220
+ rt.db.prepare('UPDATE aml_models SET metrics_json = ? WHERE id = ?').run(JSON.stringify(metrics), modelId)
221
+ modelUpdate = `\nmodel_id: ${modelId}\nmodel_twin_eligibility: ${JSON.stringify(twinEligibility)}`
222
+ }
223
+ else modelUpdate = `\nmodel_id: ${modelId}\nmodel_twin_eligibility: MODEL_NOT_FOUND`
224
+ }
225
+ return ok(JSON.stringify({ ...result, modelUpdate }, null, 2))
151
226
  }
152
227
  catch (err) {
153
228
  return fail(`Twin Gate 评估失败:${err instanceof Error ? err.message : String(err)}`)
@@ -1,124 +1,140 @@
1
- /**
2
- * 启动单个作业(环境探测 / 参数组装 / 分支到 stub 或真实进程)
3
- * (由 server/services/workshop/aml/job-orchestrator.ts 按职责拆出;内容逐行原文搬运)
4
- */
5
- import type { AmlJobRow } from '../aml.repo'
6
- import type { RunningJob } from './shared'
7
- import { concludeJob, runStub } from './stages'
8
- import { ensureVenv, platformPythonDir } from '../python-runtime'
9
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
10
- import { experimentFail, failPermanentOrRetry, finishRun, lastErrLine, safeParse } from './failure'
11
- import { getAmlRuntime } from '../runtime'
12
- import { hashDatasetDir } from '../dataset-builder'
13
- import { join } from 'node:path'
14
- import { runPython } from './process'
15
- import { state } from './shared'
16
- import { wsStage } from './broadcast'
17
-
18
- export async function startJob(row: AmlJobRow): Promise<void> {
19
- const rt = getAmlRuntime()
20
- const st = state()
21
- const dataset = rt.repo.dataset.get(row.datasetId)
22
- if (!dataset) {
23
- rt.repo.job.fail(row.id, 'failed', '数据集已被删除(永久错误)', new Date().toISOString())
24
- return
25
- }
26
- // 快照完整性校验(防篡改;sha 失配 = 永久错误不重试)
27
- const actualSha = hashDatasetDir(dataset.path, dataset.specJson)
28
- if (actualSha !== dataset.sha256) {
29
- rt.repo.job.fail(row.id, 'failed', `数据集快照校验失败(sha256 不匹配,疑似被改动或损坏):${dataset.id}(永久错误)`, new Date().toISOString())
30
- return
31
- }
32
- const jobDir = join(rt.jobsDir, row.id)
33
- const workspace = join(jobDir, 'workspace')
34
- const artifacts = join(jobDir, 'artifacts')
35
- mkdirSync(workspace, { recursive: true })
36
- mkdirSync(artifacts, { recursive: true })
37
- let trainFile = existsSync(join(workspace, 'train.py')) ? 'train.py' : null
38
- if (!trainFile) {
39
- // REST 内联代码路径:预算袋携带 inlineCode 时落盘(≤500KB,提交入口已限)
40
- const inline = safeParse(row.budget).inlineCode
41
- if (typeof inline === 'string' && inline.length > 0 && inline.length <= 512_000) {
42
- writeFileSync(join(workspace, 'train.py'), inline)
43
- trainFile = 'train.py'
44
- }
45
- else {
46
- rt.repo.job.fail(row.id, 'failed', 'workspace/train.py 不存在(Agent 用 aml_job_submit 携带 code 提交,或 REST 提交时带 code)', new Date().toISOString())
47
- return
48
- }
49
- }
50
-
51
- writeFileSync(join(jobDir, 'job.json'), JSON.stringify({
52
- jobId: row.id,
53
- datasetPath: dataset.path,
54
- workspaceDir: workspace,
55
- params: safeParse(row.budget).params ?? {},
56
- seed: safeParse(row.budget).seed ?? 42,
57
- trainFile,
58
- }, null, 2))
59
- const run: RunningJob = {
60
- row,
61
- jobDir,
62
- child: null,
63
- startedAt: Date.now(),
64
- lastProtocolAt: Date.now(),
65
- lastWsAt: 0,
66
- logLines: [],
67
- protocolBadLines: 0,
68
- killed: false,
69
- }
70
- st.running.set(row.id, run)
71
- rt.repo.job.markStarted(row.id, 'provisioning', 'provision', new Date().toISOString())
72
- wsStage(row, 'provision')
73
-
74
- try {
75
- if (process.env.AML_STUB === '1') {
76
- await runStub(run, artifacts)
77
- return
78
- }
79
- const vpy = await ensureVenv(rt)
80
- if (run.killed) return
81
- rt.repo.job.setState(row.id, 'training', 'train', 5)
82
- wsStage(row, 'train')
83
- // 第一拍:Agent 训练代码(独立进程;崩溃不伤评估器)
84
- const exit1 = await runPython(run, vpy, [join(workspace, trainFile)], workspace)
85
- if (run.killed) return
86
- if (exit1.code !== 0) {
87
- failPermanentOrRetry(run, `训练进程退出(${exit1.code}):${lastErrLine(exit1.errTail)}`, exit1.code)
88
- return
89
- }
90
- if (!existsSync(join(artifacts, 'model.onnx'))) {
91
- rt.repo.job.fail(row.id, 'failed', '训练未产出 artifacts/model.onnx(契约缺失,永久错误不重试)', new Date().toISOString())
92
- experimentFail(run, '契约缺失:model.onnx 未产出')
93
- finishRun(run)
94
- return
95
- }
96
- // 第二拍:平台权威评估
97
- rt.repo.job.setState(row.id, 'evaluating', 'evaluate', 80)
98
- wsStage(row, 'evaluate')
99
- const evalScript = join(platformPythonDir(), 'aml_eval.py')
100
- const exit2 = await runPython(run, vpy, [evalScript, '--job', join(jobDir, 'job.json')], jobDir)
101
- if (run.killed) return
102
- if (exit2.code !== 0) {
103
- rt.repo.job.fail(row.id, 'failed', `评估失败:${lastErrLine(exit2.errTail)}(永久错误不重试)`, new Date().toISOString())
104
- experimentFail(run, `评估失败:${lastErrLine(exit2.errTail)}`)
105
- finishRun(run)
106
- return
107
- }
108
- concludeJob(run, artifacts)
109
- }
110
- catch (err) {
111
- if (!run.killed) {
112
- const msg = err instanceof Error ? err.message : String(err)
113
- const permanent = String((err as { code?: string })?.code ?? '').startsWith('AML_')
114
- if (permanent) {
115
- rt.repo.job.fail(row.id, 'failed', msg, new Date().toISOString())
116
- experimentFail(run, msg)
117
- finishRun(run)
118
- }
119
- else {
120
- failPermanentOrRetry(run, msg, 1)
121
- }
122
- }
123
- }
124
- }
1
+ /**
2
+ * 启动单个作业(环境探测 / 参数组装 / 分支到 stub 或真实进程)
3
+ * (由 server/services/workshop/aml/job-orchestrator.ts 按职责拆出;内容逐行原文搬运)
4
+ */
5
+ import type { AmlJobRow } from '../aml.repo'
6
+ import type { RunningJob } from './shared'
7
+ import { concludeJob, runStub } from './stages'
8
+ import { ensureVenv, platformPythonDir } from '../python-runtime'
9
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
10
+ import { experimentFail, failPermanentOrRetry, finishRun, lastErrLine, safeParse } from './failure'
11
+ import { getAmlRuntime } from '../runtime'
12
+ import { hashDatasetDir } from '../dataset-builder'
13
+ import { join } from 'node:path'
14
+ import { runPython } from './process'
15
+ import { state } from './shared'
16
+ import { wsStage } from './broadcast'
17
+
18
+ export async function startJob(row: AmlJobRow): Promise<void> {
19
+ const rt = getAmlRuntime()
20
+ const st = state()
21
+ const dataset = rt.repo.dataset.get(row.datasetId)
22
+ if (!dataset) {
23
+ rt.repo.job.fail(row.id, 'failed', '数据集已被删除(永久错误)', new Date().toISOString())
24
+ return
25
+ }
26
+ // 快照完整性校验(防篡改;sha 失配 = 永久错误不重试)
27
+ const actualSha = hashDatasetDir(dataset.path, dataset.specJson)
28
+ if (actualSha !== dataset.sha256) {
29
+ rt.repo.job.fail(row.id, 'failed', `数据集快照校验失败(sha256 不匹配,疑似被改动或损坏):${dataset.id}(永久错误)`, new Date().toISOString())
30
+ return
31
+ }
32
+ const jobDir = join(rt.jobsDir, row.id)
33
+ const workspace = join(jobDir, 'workspace')
34
+ const artifacts = join(jobDir, 'artifacts')
35
+ mkdirSync(workspace, { recursive: true })
36
+ mkdirSync(artifacts, { recursive: true })
37
+ let trainFile = existsSync(join(workspace, 'train.py')) ? 'train.py' : null
38
+ if (!trainFile) {
39
+ // REST 内联代码路径:预算袋携带 inlineCode 时落盘(≤500KB,提交入口已限)
40
+ const inline = safeParse(row.budget).inlineCode
41
+ if (typeof inline === 'string' && inline.length > 0 && inline.length <= 512_000) {
42
+ writeFileSync(join(workspace, 'train.py'), inline)
43
+ trainFile = 'train.py'
44
+ }
45
+ else {
46
+ rt.repo.job.fail(row.id, 'failed', 'workspace/train.py 不存在(Agent 用 aml_job_submit 携带 code 提交,或 REST 提交时带 code)', new Date().toISOString())
47
+ return
48
+ }
49
+ }
50
+
51
+ const budget = safeParse(row.budget) as Record<string, unknown>
52
+ writeFileSync(join(jobDir, 'job.json'), JSON.stringify({
53
+ jobId: row.id,
54
+ datasetPath: dataset.path,
55
+ workspaceDir: workspace,
56
+ params: budget.params ?? {},
57
+ seed: budget.seed ?? 42,
58
+ trainFile,
59
+ sceneId: budget.sceneId ?? null,
60
+ sceneVersion: budget.sceneVersion ?? null,
61
+ objectiveId: budget.objectiveId ?? null,
62
+ jobKind: budget.jobKind ?? 'supervised',
63
+ }, null, 2))
64
+ // Hybrid Twin lineage is immutable job input. Persist these sidecars in the
65
+ // isolated workspace so amlkit.load_* can consume them without trusting REST
66
+ // callers or reaching into the live database during training.
67
+ for (const [name, key] of [
68
+ ['physics_manifest.json', 'physicsManifest'],
69
+ ['twin_snapshot.json', 'twinSnapshot'],
70
+ ['objective_profile.json', 'objectiveProfile'],
71
+ ] as const) {
72
+ const value = budget[key]
73
+ if (value && typeof value === 'object') writeFileSync(join(workspace, name), JSON.stringify(value, null, 2))
74
+ }
75
+ const run: RunningJob = {
76
+ row,
77
+ jobDir,
78
+ child: null,
79
+ startedAt: Date.now(),
80
+ lastProtocolAt: Date.now(),
81
+ lastWsAt: 0,
82
+ logLines: [],
83
+ protocolBadLines: 0,
84
+ killed: false,
85
+ }
86
+ st.running.set(row.id, run)
87
+ rt.repo.job.markStarted(row.id, 'provisioning', 'provision', new Date().toISOString())
88
+ wsStage(row, 'provision')
89
+
90
+ try {
91
+ if (process.env.AML_STUB === '1') {
92
+ await runStub(run, artifacts)
93
+ return
94
+ }
95
+ const vpy = await ensureVenv(rt)
96
+ if (run.killed) return
97
+ rt.repo.job.setState(row.id, 'training', 'train', 5)
98
+ wsStage(row, 'train')
99
+ // 第一拍:Agent 训练代码(独立进程;崩溃不伤评估器)
100
+ const exit1 = await runPython(run, vpy, [join(workspace, trainFile)], workspace)
101
+ if (run.killed) return
102
+ if (exit1.code !== 0) {
103
+ failPermanentOrRetry(run, `训练进程退出(${exit1.code}):${lastErrLine(exit1.errTail)}`, exit1.code)
104
+ return
105
+ }
106
+ if (!existsSync(join(artifacts, 'model.onnx'))) {
107
+ rt.repo.job.fail(row.id, 'failed', '训练未产出 artifacts/model.onnx(契约缺失,永久错误不重试)', new Date().toISOString())
108
+ experimentFail(run, '契约缺失:model.onnx 未产出')
109
+ finishRun(run)
110
+ return
111
+ }
112
+ // 第二拍:平台权威评估
113
+ rt.repo.job.setState(row.id, 'evaluating', 'evaluate', 80)
114
+ wsStage(row, 'evaluate')
115
+ const evalScript = join(platformPythonDir(), 'aml_eval.py')
116
+ const exit2 = await runPython(run, vpy, [evalScript, '--job', join(jobDir, 'job.json')], jobDir)
117
+ if (run.killed) return
118
+ if (exit2.code !== 0) {
119
+ rt.repo.job.fail(row.id, 'failed', `评估失败:${lastErrLine(exit2.errTail)}(永久错误不重试)`, new Date().toISOString())
120
+ experimentFail(run, `评估失败:${lastErrLine(exit2.errTail)}`)
121
+ finishRun(run)
122
+ return
123
+ }
124
+ concludeJob(run, artifacts)
125
+ }
126
+ catch (err) {
127
+ if (!run.killed) {
128
+ const msg = err instanceof Error ? err.message : String(err)
129
+ const permanent = String((err as { code?: string })?.code ?? '').startsWith('AML_')
130
+ if (permanent) {
131
+ rt.repo.job.fail(row.id, 'failed', msg, new Date().toISOString())
132
+ experimentFail(run, msg)
133
+ finishRun(run)
134
+ }
135
+ else {
136
+ failPermanentOrRetry(run, msg, 1)
137
+ }
138
+ }
139
+ }
140
+ }
@@ -94,14 +94,29 @@ export class InjectionGreyboxProvider implements PhysicsModelProvider {
94
94
  }
95
95
 
96
96
  evaluateConstraints(scene: SceneContract, trajectory: PhysicsTrajectory): Array<{ id: string, passed: boolean, detail: string, firstViolationStep?: number }> {
97
+ // 失败关闭(fail-closed):约束 id 必须能映射到轨迹观测量/守卫量。
98
+ // 旧行为对映射不到的 id 直接跳过并返回「全轨迹通过」(value == null 分支),
99
+ // 于是 scene_json 里写成 part_weight / 质量窗口 这类 id 时,硬约束门禁形同虚设
100
+ // —— 实测被真实 Agent 团队两次抓出:预测 weight 30.3 g 明显低于窗口下界仍报通过。
101
+ const known = [...new Set([
102
+ ...Object.keys(trajectory.steps.at(-1)?.observations ?? {}),
103
+ ...Object.keys(trajectory.steps.at(-1)?.guards ?? {}),
104
+ ])]
105
+ if (trajectory.steps.length === 0) {
106
+ return scene.constraints.map(constraint => ({ id: constraint.id, passed: false, detail: '空轨迹:未执行任何校验' }))
107
+ }
97
108
  return scene.constraints.map((constraint) => {
109
+ let unmapped = true
98
110
  for (let i = 0; i < trajectory.steps.length; i++) {
99
111
  const obs = trajectory.steps[i]?.observations ?? {}
100
112
  const value = obs[constraint.id] ?? trajectory.steps[i]?.guards[constraint.id]
101
- if (constraint.kind === 'hard_range' && value != null && ((constraint.min != null && value < constraint.min) || (constraint.max != null && value > constraint.max))) {
113
+ if (value == null) continue
114
+ unmapped = false
115
+ if (constraint.kind === 'hard_range' && ((constraint.min != null && value < constraint.min) || (constraint.max != null && value > constraint.max))) {
102
116
  return { id: constraint.id, passed: false, detail: `${constraint.id}=${value} 越过 ${constraint.min ?? '-∞'}~${constraint.max ?? '∞'}`, firstViolationStep: i }
103
117
  }
104
118
  }
119
+ if (unmapped) return { id: constraint.id, passed: false, detail: `约束 ${constraint.id} 无法映射到观测量/守卫量(可用:${known.join(', ') || '无'}),按失败关闭处理` }
105
120
  return { id: constraint.id, passed: true, detail: '全轨迹通过' }
106
121
  })
107
122
  }
@@ -8,19 +8,24 @@ export function createTwinRepo(db: DatabaseSync) {
8
8
  const insertSnapshotStmt = db.prepare(`INSERT OR IGNORE INTO twin_snapshots(id,scene_id,scene_version,line_id,product_id,recipe_id,phase,snapshot_hash,watermark_ms,freshness_json,payload_json,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`)
9
9
  const insertTrialStmt = db.prepare(`INSERT OR IGNORE INTO twin_trials(id,snapshot_id,twin_model_id,objective_id,candidate_executed,status,result_json,result_hash,created_by,created_at) VALUES(?,?,?,?,0,?,?,?,?,?)`)
10
10
  const insertRecStmt = db.prepare(`INSERT OR IGNORE INTO twin_recommendations(id,trial_id,certificate_hash,status,certificate_json,expires_at,created_by,created_at) VALUES(?,?,?,?,?,?,?,?)`)
11
+ // 绑定值直接来自调用方(scene_json/snapshot_json 都是外部输入):对象/undefined 会以
12
+ // "Provided value cannot be bound to SQLite parameter N" 这种驱动级报错冒出来(实测踩过),
13
+ // 这里统一收敛成字符串/有限数字,让参数形状问题不再污染错误面。
14
+ const text = (v: unknown): string => (v == null ? '' : typeof v === 'string' ? v : String(v))
15
+ const num = (v: unknown): number => (Number.isFinite(Number(v)) ? Number(v) : 0)
11
16
  return {
12
17
  upsertScene(scene: SceneContract, createdBy: string): void {
13
18
  const now = new Date().toISOString()
14
- upsertSceneStmt.run(scene.sceneId, scene.sceneVersion, scene.schemaVersion, JSON.stringify(scene), sha256(scene), scene.lineId, scene.productId ?? '', scene.recipeId ?? '', 'draft', createdBy, scene.createdAt || now, now)
19
+ upsertSceneStmt.run(text(scene.sceneId), text(scene.sceneVersion), num(scene.schemaVersion), JSON.stringify(scene), sha256(scene), text(scene.lineId), text(scene.productId), text(scene.recipeId), 'draft', text(createdBy), text(scene.createdAt) || now, now)
15
20
  },
16
21
  insertSnapshot(snapshot: TwinSnapshot): void {
17
- insertSnapshotStmt.run(snapshot.snapshotId, snapshot.sceneId, snapshot.sceneVersion, snapshot.lineId, snapshot.productId ?? '', snapshot.recipeId ?? '', snapshot.phase, snapshot.snapshotHash, snapshot.daqWatermark, JSON.stringify(snapshot.dataQuality), JSON.stringify(snapshot), snapshot.createdBy, snapshot.createdAt)
22
+ insertSnapshotStmt.run(text(snapshot.snapshotId), text(snapshot.sceneId), text(snapshot.sceneVersion), text(snapshot.lineId), text(snapshot.productId), text(snapshot.recipeId), text(snapshot.phase), text(snapshot.snapshotHash), num(snapshot.daqWatermark), JSON.stringify(snapshot.dataQuality ?? {}), JSON.stringify(snapshot), text(snapshot.createdBy), text(snapshot.createdAt))
18
23
  },
19
24
  insertTrial(trial: VirtualTrial, twinModelId?: string): void {
20
- insertTrialStmt.run(trial.trialId, trial.snapshotId, twinModelId ?? trial.modelId, trial.objectiveId, 'completed', JSON.stringify(trial), sha256(trial), trial.createdBy, trial.createdAt)
25
+ insertTrialStmt.run(text(trial.trialId), text(trial.snapshotId), text(twinModelId ?? trial.modelId), text(trial.objectiveId), 'completed', JSON.stringify(trial), sha256(trial), text(trial.createdBy), text(trial.createdAt))
21
26
  },
22
27
  insertRecommendation(certificate: RecommendationCertificate, createdBy: string, expiresAt?: string): void {
23
- insertRecStmt.run(certificate.recommendationId, certificate.trialId, certificate.certificateHash, certificate.status.toLowerCase(), JSON.stringify(certificate), expiresAt ?? null, createdBy, certificate.createdAt)
28
+ insertRecStmt.run(text(certificate.recommendationId), text(certificate.trialId), text(certificate.certificateHash), text(certificate.status).toLowerCase(), JSON.stringify(certificate), expiresAt ?? null, text(createdBy), text(certificate.createdAt))
24
29
  },
25
30
  }
26
31
  }
@@ -1 +0,0 @@
1
- {"id":"7827fe9f-b4c2-461f-b1fb-c0c7da7d66b8","timestamp":1790316372736,"prerendered":[]}