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.
- package/.output/.AgentWorkShop/aml-python/__pycache__/amlkit.cpython-312.pyc +0 -0
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/6f524f3b-53e1-4df9-84e6-84d8058b9f16.json +1 -0
- package/.output/server/chunks/_/nitro.mjs +955 -858
- package/.output/server/package.json +1 -1
- package/README-zh.md +7 -2
- package/README.md +7 -2
- package/config.yml +1 -1
- package/docs/aml.md +2 -0
- package/docs/cli.en.md +2 -2
- package/docs/cli.md +2 -2
- package/docs/full-test-plan.md +1 -1
- package/docs/plugins.en.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/sdk.en.md +1 -1
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/scripts/_aw-packaged-start.cjs +22 -0
- package/scripts/_aw0746-stage12.mjs +94 -0
- package/scripts/_aw0746-stage34.mjs +136 -0
- package/scripts/_aw0746-stage5.mjs +144 -0
- package/scripts/_aw0746-stage6-aml.mjs +177 -0
- package/scripts/_aw0746-stage6b-train-sweep.mjs +145 -0
- package/scripts/_aw0746-stage6c-jobsmoke.mjs +69 -0
- package/scripts/_aw0746-stage7-twin.mjs +267 -0
- package/scripts/_aw0746-train-strong.py +142 -0
- package/scripts/_aw0747-readme-rows.mjs +32 -0
- package/scripts/_aw0748-failclosed-probe.mjs +90 -0
- package/scripts/_plc-sim-detached.cjs +22 -0
- package/server/services/workshop/agents/industrial/twin-tools.ts +85 -10
- package/server/services/workshop/aml/job-orchestrator/start-job.ts +140 -124
- package/server/services/workshop/aml/python/__pycache__/amlkit.cpython-312.pyc +0 -0
- package/server/services/workshop/aml/twin/physics-runtime.ts +16 -1
- package/server/services/workshop/aml/twin/repo.ts +9 -4
- 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
|
|
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(
|
|
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'))
|
|
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
|
|
69
|
-
nowMs
|
|
70
|
-
freshnessMaxMs
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
rt.repo.job.setState(row.id, '
|
|
98
|
-
wsStage(row, '
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
if (run.killed) return
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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":[]}
|