dsh-labscript 0.2.7 → 0.2.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.8
4
+
5
+ Fixes from a real labscript-suite session (2026-11-14, 145 tool calls, no run submitted by the
6
+ plugin). Each defect was reproduced against the stock runmanager source before the fix.
7
+
8
+ - Fix: labs_set failed with "TypeError: 'NoneType' object is not iterable" after the write had
9
+ already landed. runmanager's set_globals RPC is a side-effect call whose handler returns None,
10
+ and the bridge fed that reply to dict(). It now reports the requested values and lets the
11
+ readback decide success. When a write really fails, the error carries `landed` (what the panel
12
+ holds now) and the hint says to read the keys back instead of claiming the panel may be half
13
+ written and pointing at labs_restore.
14
+ - Fix: labs_wait blocked for the whole timeout when compilation aborted. It returns after
15
+ engage.noShotS seconds (default 25) with diagnosis no_shots (no shot file appeared at all) or
16
+ compile_stalled (files stopped appearing), so the runmanager compile error can be read instead
17
+ of waiting 5-15 minutes. The hint says to call labs_wait again if a slow compile is expected.
18
+ - Fix: a pending run that never produced a shot blocked labs_engage forever. A pending submission
19
+ with zero shots and older than engage.staleS (default 180 s) is dropped and reported as
20
+ dropped_stale_pending; the RUN_PENDING error now states the run's age and both ways out.
21
+ - Fix: with pathMap configured, labs_wait(run_folder=<the folder labs_engage printed>) failed with
22
+ RUN_FOLDER_MISSING: the pending run kept the unmapped path and the wait compared strings. Pending
23
+ state now stores the mapped folder (raw kept as remote_run_folder) and both forms match.
24
+ - Fix: shot h5 files that only hold compiled globals were counted as progress, so h5=1/64 looked
25
+ like "a shot ran". Progress reports n_h5 (files) and n_executed (shots with acquisition data,
26
+ located by binary search over the file order), completion requires acquisition data in the last
27
+ shot, and labs_status renders executed=.
28
+ - labs_protocol show prints the closest key names for plan keys missing from the loaded labscript
29
+ (previously only the run path did), so stale protocols can be renamed without guessing.
30
+ - labs_set description: one call can mix scalars, an expression scan axis and reps=N.
31
+ - Tests: 28 python + 46 node, adding a client whose set_globals returns None, a stalled
32
+ compilation, a stale zero-shot pending run, mapped/raw pending folders and the show suggestions.
33
+
3
34
  ## 0.2.7
4
35
 
5
36
  - Fix: labs_wait no longer fails with "value is not lossless JSON". Tool results must be
package/README.md CHANGED
@@ -103,6 +103,14 @@ already mapped: `labs_status`, `labs_engage`, `labs_wait`, `labs_data` and
103
103
  `labs_protocol` show local paths, while `run_folder` keeps the raw runmanager
104
104
  value next to `mapped_run_folder`.
105
105
 
106
+ `engage` controls how a submitted run is watched: `timeoutS` (default 900) is the
107
+ total wait budget of one `labs_wait`, `noShotS` (default 25) makes the wait return
108
+ early with a diagnosis when no shot appeared, `staleS` (default 180) drops a pending
109
+ submission that never produced a single shot, `pollS`, `settleS` and `stallS` tune the
110
+ polling and the compiled-not-executed detection.
111
+
112
+ engage: { timeoutS: 900, noShotS: 25, staleS: 180, stallS: 20 }
113
+
106
114
  The python interpreter must be able to import numpy and h5py; the real backend
107
115
  also needs runmanager (labscript-suite). The plugin never stores credentials:
108
116
  zprocess authentication is read by labscript itself from labconfig.
@@ -125,8 +133,8 @@ is not a physics simulation.
125
133
  ## Development
126
134
 
127
135
  npm install # only runtime dependency: yaml
128
- npm test # node --test, 40 tests (fake bridge, no python needed)
129
- python python/tests/run_tests.py # 22 tests (needs numpy + h5py)
136
+ npm test # node --test, 46 tests (fake bridge, no python needed)
137
+ python python/tests/run_tests.py # 28 tests (needs numpy + h5py)
130
138
  LABSCRIPT_PYTHON=/path/to/python3 node scripts/demo.mjs # end-to-end mock demo
131
139
 
132
140
  A fresh clone must run `npm install` before `npm test`; the CI workflow does this.
@@ -153,9 +161,19 @@ A fresh clone must run `npm install` before `npm test`; the CI workflow does thi
153
161
  explicitly when something must stop; labs_abort also cancels an in-flight labs_wait.
154
162
  labscript abort stops compiling/submitting new shots; shots already queued in BLACS
155
163
  or running are not stopped.
156
- - labs_wait reports a diagnosis (complete, no_shots, compiling, compiled_not_executed,
157
- partial). compiled_not_executed means shots exist but show no acquisition data yet;
158
- BLACS may be paused or not executing the queue.
164
+ - labs_wait reports a diagnosis (complete, no_shots, compile_stalled, compiling,
165
+ compiled_not_executed, partial). no_shots and compile_stalled come back before the
166
+ timeout when nothing new appeared for engage.noShotS seconds: runmanager probably
167
+ aborted the compilation, so read its output. compiled_not_executed means shots were
168
+ compiled but show no acquisition data yet; BLACS may be paused or not executing the queue.
169
+ - labs_engage only reports that runmanager accepted the submission; compilation errors are
170
+ invisible to it. A pending submission with no shots at all is dropped after
171
+ engage.staleS seconds and reported as dropped_stale_pending, so a dead run does not block
172
+ the next one forever.
173
+ - Shot h5 files exist before they are executed: progress reports n_h5 (files) and
174
+ n_executed (shots with acquisition data), and completion requires acquisition data.
175
+ - The pending run is stored under the pathMap-mapped folder (the raw runmanager path is kept
176
+ as remote_run_folder), so labs_wait accepts whichever of the two an agent quotes back.
159
177
  - Real runmanager engage does not return a run folder; the bridge captures the
160
178
  output folder before engage and reports it as the run folder. That shot output
161
179
  folder is the NEXT run's folder and does not exist until engage, so labs_data
package/docs/README.zh.md CHANGED
@@ -11,7 +11,7 @@ hard limits 全部通过配置接入。
11
11
  |---|---|
12
12
  | labs_doctor | 只读诊断:python、numpy/h5py、labscript/runmanager、路径、审批、版本 |
13
13
  | labs_status | 读面板:labscript、globals、n_shots、error_in_globals、run_shots、pending 进度 |
14
- | labs_set | 写面板(标量 / raw 表达式 / reps),写前全量快照;不运行 |
14
+ | labs_set | 写面板(标量 / raw 表达式 / reps 可一次混用),写前全量快照;不运行 |
15
15
  | labs_engage | 提交当前面板,立即返回 n_shots_at_submit 与 expected_n_shots |
16
16
  | labs_wait | 等待 pending run(可超时);若是协议则收尾:postprocess → finish 写回 → restore |
17
17
  | labs_abort | 中止当前提交(不排队,不会被 labs_wait 堵住) |
@@ -28,8 +28,8 @@ hard limits 全部通过配置接入。
28
28
  真机用 labscript-suite 环境。详见 docs/INSTALL.zh.md。
29
29
  - 默认 backend 是 mock-labscript;真机必须显式配置 backend: labscript。
30
30
  - 安装后在 DSH 插件页填 backend、python、runmanager host/port、protocolsDir;
31
- 其余高级配置(scriptsFile、hard limits、postprocess、approval、pathMap)写在
32
- profile 的 cordis.patch.yml。
31
+ 其余高级配置(scriptsFile、hard limits、postprocess、approval、pathMap、
32
+ engage 的超时/早退参数)写在 profile 的 cordis.patch.yml。
33
33
 
34
34
  ## 协议 YAML
35
35
 
@@ -52,6 +52,14 @@ postprocess 是后处理:跑完后调用配置的 Python 函数(entry + pyth
52
52
  - 真机 engage / protocol run 会请求 DSH 审批(一次),abort 和面板写入不请求;
53
53
  插件不是硬件联锁。
54
54
  - labscript abort 只停止编译/提交新的 shot,已进 BLACS 队列或正在跑的 shot 不会被停。
55
- - 完成判定基于 HDF5 数量、大小稳定和最后一发可读;BLACS paused 会报
56
- compiled_not_executed 诊断。
55
+ - 完成判定基于 HDF5 数量、大小稳定和最后一发可读。shot 的 h5 文件在编译阶段就写了出来,
56
+ 所以 labs_status 分别报 h5(文件数)和 executed(真正采到数据的枪数),完成要求最后一发
57
+ 有采集数据;BLACS paused 会报 compiled_not_executed 诊断。
58
+ - 编译 abort 时 labs_wait 不再等满 timeout:距今没有任何新 shot 超过 engage.noShotS
59
+ (默认 25 s)就返回 no_shots / compile_stalled 诊断,去 runmanager 看编译错误;如果只是
60
+ 编译慢,再调一次 labs_wait 继续等。
61
+ - 一条一枪都没产生的 pending 提交,超过 engage.staleS(默认 180 s)会被下一次 labs_engage
62
+ 丢弃并报 dropped_stale_pending,不再永久挡住提交;RUN_PENDING 的错误里也会写清年龄和两种出路。
63
+ - 配了 pathMap 时 pending 记录映射后的目录(原始路径保留为 remote_run_folder),所以
64
+ labs_wait 传映射后目录或原始目录都能对上,不会再说"run folder does not exist"。
57
65
 
package/lib/skill.js CHANGED
@@ -21,9 +21,13 @@ export const SKILL_CONTENT = [
21
21
  '## Running',
22
22
  '- labs_set writes the panel. It does not run anything. Always pass note: one sentence',
23
23
  ' saying what this batch is for (it is written to the event log).',
24
+ '- labs_engage only reports that runmanager accepted the submission; it cannot see',
25
+ ' compile errors. If nothing runs, the next labs_wait says so (see Stop rules).',
24
26
  '- labs_engage submits a run and returns immediately with n_shots_at_submit and',
25
27
  ' expected_n_shots. Do not poll in a loop; when the user wants results, call labs_wait.',
26
28
  ' After submitting you may end the turn or do independent work.',
29
+ '- n_h5 counts shot h5 files, which labscript writes before compiling each shot; only',
30
+ ' n_executed (or the diagnosis) says whether shots actually acquired data.',
27
31
  '- labs_protocol run submits a fixed protocol; labs_wait finalizes it (postprocess,',
28
32
  ' finish write-back, panel restore).',
29
33
  '- backend defaults to mock-labscript; real hardware is only used when the config says',
@@ -43,8 +47,15 @@ export const SKILL_CONTENT = [
43
47
  '',
44
48
  '## Stop rules',
45
49
  '- error_in_globals true: fix or ask; never engage.',
46
- '- wait timed out or diagnosis=compiled_not_executed: the shots may be compiled but BLACS',
47
- ' may be paused/not executing. Check BLACS and the panel, then abort or ask; do not resubmit blindly.',
50
+ '- labs_wait returns before the timeout with diagnosis no_shots or compile_stalled when no',
51
+ ' shot appeared for config.engage.noShotS seconds (default 25). That usually means runmanager',
52
+ ' aborted the compilation: read the runmanager output, then labs_abort, or labs_wait again if',
53
+ ' you expect a slow compile. A submission that never produced a shot is dropped by the next',
54
+ ' labs_engage once it is config.engage.staleS seconds old (default 180) and reported as',
55
+ ' dropped_stale_pending.',
56
+ '- diagnosis=compiled_not_executed, or timed_out with h5 files but no acquisition data: the',
57
+ ' shots were compiled but BLACS may be paused/not executing. Check BLACS and the panel, then',
58
+ ' abort or ask; do not resubmit blindly.',
48
59
  '- A run is already pending: wait or abort before submitting another.',
49
60
  ].join(String.fromCharCode(10))
50
61
 
package/lib/tools.js CHANGED
@@ -30,6 +30,9 @@ function progressLine(run) {
30
30
  if (run.kind) parts.push('kind=' + run.kind)
31
31
  if (run.n_h5 !== undefined) parts.push('h5=' + run.n_h5 + '/' + (run.expected_n_shots || '?'))
32
32
  else if (run.expected_n_shots) parts.push('expected=' + run.expected_n_shots)
33
+ // h5 counts files; labscript writes each file before compiling it, so a compiled
34
+ // but not yet executed run shows h5>0 until the first acquisition data appears.
35
+ if (run.n_executed !== undefined) parts.push('executed=' + run.n_executed)
33
36
  if (run.complete !== undefined) parts.push('complete=' + run.complete)
34
37
  if (run.elapsed_s !== undefined) parts.push('elapsed=' + run.elapsed_s + 's')
35
38
  return parts.join(' ')
@@ -82,6 +85,24 @@ function resultsLine(value) {
82
85
  return 'results: ' + names.map((group) => group + ' (' + layout[group].length + ' fields)').join(', ')
83
86
  }
84
87
 
88
+ // Protocol keys that the loaded labscript does not define, with the closest names it
89
+ // does define: a renamed panel global is the most common reason a protocol is stale.
90
+ function nameCheckDetail(check) {
91
+ const missing = check && Array.isArray(check.missing) ? check.missing : []
92
+ return missing.map((key) => {
93
+ const suggestions = (check.suggestions || {})[key] || []
94
+ return key + (suggestions.length ? ' (closest: ' + suggestions.join(', ') + ')' : '')
95
+ }).join(', ')
96
+ }
97
+
98
+ function nameCheckLine(value) {
99
+ const check = value.name_check
100
+ if (!check) return ''
101
+ if (check.checked === false) return 'script key check: not checked (' + (check.reason || 'no script') + ')'
102
+ const detail = nameCheckDetail(check)
103
+ return detail ? 'script key check: MISSING ' + detail : ''
104
+ }
105
+
85
106
  function dataGlobalsLines(args, value) {
86
107
  const globals = value.globals
87
108
  if (!globals) return []
@@ -166,23 +187,57 @@ async function requireApproval(runtime, exec, toolName, reason, approvalKey) {
166
187
  }
167
188
  }
168
189
 
190
+ // The pending run may be stored under either path form: labs_engage returns the
191
+ // mapped folder, the bridge also reports the raw runmanager folder, and an agent may
192
+ // quote either one back.
193
+ function pendingFolders(pending) {
194
+ if (!pending) return []
195
+ return [pending.run_folder, pending.mapped_run_folder, pending.remote_run_folder].filter((folder) => Boolean(folder))
196
+ }
197
+
198
+ function isPendingRun(pending, folder) {
199
+ return pendingFolders(pending).includes(folder)
200
+ }
201
+
202
+ // Returns the dropped run description when a dead submission was cleared.
169
203
  async function clearStalePending(runtime, exec) {
170
204
  const pending = runtime.state.pending
171
- if (!pending) return
205
+ if (!pending) return null
172
206
  if (pending.run_folder) {
207
+ let progress = null
173
208
  try {
174
- const progress = await runtime.runBridge('progress', {
209
+ progress = await runtime.runBridge('progress', {
175
210
  run_folder: pending.run_folder,
176
211
  expected_n_shots: pending.expected_n_shots,
177
212
  }, { signal: exec.signal })
178
- if (!progress.complete) {
179
- throw new LabError('RUN_PENDING', 'a run is still in progress (' + progress.n_h5 + '/' + progress.expected_n_shots + ' shots): call labs_wait first, or labs_abort')
213
+ } catch {
214
+ progress = null
215
+ }
216
+ if (progress && progress.complete !== true) {
217
+ const ageS = pending.submitted_at ? Math.round((Date.now() - Date.parse(pending.submitted_at)) / 1000) : 0
218
+ const staleS = Number((runtime.config.engage || {}).staleS || 180)
219
+ const nH5 = Number(progress.n_h5 || 0)
220
+ if (progress.folder_exists && nH5 === 0 && ageS >= staleS) {
221
+ // Nothing was ever written for this submission and it is minutes old: the
222
+ // compilation aborted, so the run folder stays empty forever. Drop it and
223
+ // report the drop instead of refusing to submit again.
224
+ const dropped = {
225
+ run_folder: progress.mapped_run_folder || pending.run_folder,
226
+ age_s: ageS,
227
+ expected_n_shots: pending.expected_n_shots,
228
+ }
229
+ runtime.clearPending()
230
+ return dropped
180
231
  }
181
- } catch (error) {
182
- if (error instanceof LabError && error.code === 'RUN_PENDING') throw error
232
+ const next = nH5 === 0
233
+ ? ' A submission with no shots is dropped automatically after ' + staleS + ' s, or call labs_abort now.'
234
+ : ' Call labs_wait to inspect it, or labs_abort to abandon it.'
235
+ throw new LabError('RUN_PENDING', 'a run is still in progress (' + nH5 + '/' + (pending.expected_n_shots || '?') +
236
+ ' shots, ' + ageS + ' s old):' + next)
183
237
  }
184
238
  }
185
239
  runtime.clearPending()
240
+ return null
186
241
  }
187
242
 
188
243
  function mockApprovalSkipped(runtime) {
@@ -227,13 +282,13 @@ export function registerTools(ctx, runtime) {
227
282
 
228
283
  ctx.tools.register({
229
284
  name: 'labs_set',
230
- description: 'Write runmanager panel globals. values are JSON scalars (bool/number/string); pass raw=true to write expression strings, or reps=N to write the repetition counter (np.arange(N)). A full panel snapshot is taken before the write. Does not run anything.',
285
+ description: 'Write runmanager panel globals. One call can do the whole step: values are JSON scalars (bool/number/string); with raw=true a string value is written as an expression (e.g. np.arange(0.5, 6.7, 0.2)) while bool/number values are still converted normally (true -> True, 2.72 -> 2.72), so scalars, an expression scan axis and reps=N can be combined. reps=N writes the configured repetition global as np.arange(N). A full panel snapshot is taken before the write. Does not run anything.',
231
286
  parameters: {
232
287
  type: 'object',
233
288
  properties: {
234
289
  values: { type: 'object', additionalProperties: true, description: 'Panel globals to write: key -> scalar' },
235
- raw: { type: 'boolean', description: 'values are raw expression strings (e.g. np.arange(0, 8, 0.3))' },
236
- reps: { type: 'integer', minimum: 1, description: 'Write the configured repetition global as np.arange(reps)' },
290
+ raw: { type: 'boolean', description: 'String values are written as expressions; bool/number values are still converted (mix them freely with reps=N)' },
291
+ reps: { type: 'integer', minimum: 1, description: 'Also write the configured repetition global as np.arange(reps)' },
237
292
  note: { type: 'string', description: 'One sentence: what this batch is for (written to the event log)' },
238
293
  },
239
294
  required: ['values'],
@@ -257,7 +312,7 @@ export function registerTools(ctx, runtime) {
257
312
 
258
313
  ctx.tools.register({
259
314
  name: 'labs_engage',
260
- description: 'Submit the current panel for a run (compiles and sends to BLACS). Returns immediately with run_folder, n_shots_at_submit and expected_n_shots; use labs_wait later to wait for completion. Refuses when error_in_globals is true or another run is still pending.',
315
+ description: 'Submit the current panel for a run (compiles and sends to BLACS). Returns immediately with run_folder, n_shots_at_submit and expected_n_shots; use labs_wait later to wait for completion. Refuses when error_in_globals is true or another run is still pending; a pending run that produced no shot at all for config.engage.staleS (default 180 s) is dropped and reported as dropped_stale_pending.',
261
316
  parameters: {
262
317
  type: 'object',
263
318
  properties: {
@@ -267,29 +322,33 @@ export function registerTools(ctx, runtime) {
267
322
  },
268
323
  isConcurrencySafe: () => false,
269
324
  output: looseOutput((_args, value) => text(lines([
325
+ value.dropped_stale_pending ? 'dropped stale pending run: ' + value.dropped_stale_pending.run_folder + ' (' + value.dropped_stale_pending.age_s + ' s old, no shots ever appeared)' : '',
270
326
  'submitted run: ' + (value.mapped_run_folder || value.run_folder || '<run folder pending>'),
271
327
  'n_shots_at_submit: ' + value.n_shots_at_submit + ' expected: ' + value.expected_n_shots,
272
328
  'call labs_wait when results are needed.',
273
329
  ]))),
274
330
  async execute(args, exec) {
275
331
  return runtime.serial(async () => {
276
- await clearStalePending(runtime, exec)
332
+ const dropped = await clearStalePending(runtime, exec)
277
333
  const before = await runtime.runBridge('status', {}, { signal: exec.signal })
278
334
  await requireApproval(runtime, exec, 'labs_engage', 'Submit a labscript run: ' + (before.labscript || '<script>') + ', n_shots=' + before.n_shots + ', backend=' + before.backend, 'engage')
279
335
  const data = await runtime.runBridge('engage', { note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
280
336
  runtime.setLastRun({
281
- run_folder: data.run_folder,
337
+ run_folder: data.mapped_run_folder || data.run_folder,
338
+ remote_run_folder: data.run_folder,
282
339
  expected_n_shots: data.expected_n_shots,
283
340
  submitted_at: data.submitted_at,
284
341
  })
285
342
  runtime.setPending({
286
343
  kind: 'engage',
287
- run_folder: data.run_folder,
344
+ run_folder: data.mapped_run_folder || data.run_folder,
345
+ remote_run_folder: data.run_folder,
288
346
  expected_n_shots: data.expected_n_shots,
289
347
  submitted_at: data.submitted_at,
290
348
  note: args.note,
291
349
  snapshot: null,
292
350
  })
351
+ if (dropped) data.dropped_stale_pending = dropped
293
352
  return data
294
353
  })
295
354
  },
@@ -297,7 +356,7 @@ export function registerTools(ctx, runtime) {
297
356
 
298
357
  ctx.tools.register({
299
358
  name: 'labs_wait',
300
- description: 'Wait for the pending run to finish (blocking, timeout in seconds). If the pending run came from labs_protocol run, this call also finalizes it: postprocess, finish write-back of panel and calibration history, and panel restore. Returns expected vs actual shots and, for protocols, the postprocess/finish/restore results. A run folder that does not exist and is not the pending run of this session fails immediately instead of waiting.',
359
+ description: 'Wait for the pending run to finish (blocking, timeout in seconds). If the pending run came from labs_protocol run, this call also finalizes it: postprocess, finish write-back of panel and calibration history, and panel restore. Returns expected vs actual shots and, for protocols, the postprocess/finish/restore results. Returns early with diagnosis no_shots or compile_stalled when no shot h5 appears for config.engage.noShotS seconds (default 25): the compilation probably aborted, so check runmanager and call labs_wait again only if you expect a slow compile. A run folder that does not exist and is not the pending run of this session fails immediately instead of waiting.',
301
360
  parameters: {
302
361
  type: 'object',
303
362
  properties: {
@@ -312,7 +371,7 @@ export function registerTools(ctx, runtime) {
312
371
  'run: ' + (value.mapped_run_folder || value.run_folder),
313
372
  value.aborted ? 'wait cancelled by labs_abort (run aborted)' : '',
314
373
  value.folder_exists === false ? 'folder: missing' : '',
315
- 'shots: ' + value.n_h5 + '/' + (value.expected_n_shots || '?') + ' complete: ' + value.complete + (value.timed_out ? ' TIMED OUT' : '') + (value.diagnosis ? ' diagnosis: ' + value.diagnosis : ''),
374
+ 'shots: ' + value.n_h5 + '/' + (value.expected_n_shots || '?') + ' h5 files' + (value.n_executed !== undefined ? ', ' + value.n_executed + ' with acquisition data' : '') + ' complete: ' + value.complete + (value.timed_out ? ' TIMED OUT' : '') + (value.diagnosis ? ' diagnosis: ' + value.diagnosis : ''),
316
375
  value.hint ? 'hint: ' + value.hint : '',
317
376
  value.finalize && value.finalize.postprocess ? 'postprocess: ' + JSON.stringify(value.finalize.postprocess).slice(0, 400) : '',
318
377
  value.finalize && value.finalize.finish ? 'finish: ' + JSON.stringify(value.finalize.finish).slice(0, 300) : '',
@@ -323,8 +382,9 @@ export function registerTools(ctx, runtime) {
323
382
  const pending = runtime.state.pending
324
383
  const runFolder = args.run_folder || (pending ? pending.run_folder : '')
325
384
  if (!runFolder) throw new LabError('NO_PENDING_RUN', 'no run folder: submit with labs_engage or labs_protocol run first, or pass run_folder')
326
- const expected = pending && pending.run_folder === runFolder ? pending.expected_n_shots : undefined
327
- if (!(pending && pending.run_folder === runFolder)) {
385
+ const pendingRun = isPendingRun(pending, runFolder)
386
+ const expected = pendingRun ? pending.expected_n_shots : undefined
387
+ if (!pendingRun) {
328
388
  let probe = null
329
389
  try {
330
390
  probe = await runtime.runBridge('progress', {
@@ -368,6 +428,7 @@ export function registerTools(ctx, runtime) {
368
428
  run_folder: waited.run_folder,
369
429
  expected_n_shots: waited.expected_n_shots,
370
430
  n_h5: waited.n_h5,
431
+ n_executed: waited.n_executed,
371
432
  complete: waited.complete,
372
433
  timed_out: waited.timed_out,
373
434
  elapsed: waited.elapsed,
@@ -378,13 +439,13 @@ export function registerTools(ctx, runtime) {
378
439
  folder_exists: waited.folder_exists,
379
440
  }
380
441
  for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key]
381
- if (waited.complete && runtime.state.lastRun && runtime.state.lastRun.run_folder === runFolder) {
442
+ if (waited.complete && runtime.state.lastRun && isPendingRun(runtime.state.lastRun, runFolder)) {
382
443
  runtime.setLastRun(Object.assign({}, runtime.state.lastRun, {
383
444
  completed_at: new Date().toISOString(),
384
445
  n_h5: waited.n_h5,
385
446
  }))
386
447
  }
387
- if (waited.complete && pending && pending.kind === 'protocol' && pending.run_folder === runFolder) {
448
+ if (waited.complete && pending && pending.kind === 'protocol' && pendingRun) {
388
449
  const finalize = await runtime.runBridge('protocol_finalize', {
389
450
  protocol: pending.plan,
390
451
  run_folder: runFolder,
@@ -393,7 +454,7 @@ export function registerTools(ctx, runtime) {
393
454
  }, { signal: exec.signal, timeoutMs: 180000 })
394
455
  out.finalize = finalize
395
456
  runtime.clearPending()
396
- } else if (waited.complete && pending && pending.run_folder === runFolder) {
457
+ } else if (waited.complete && pending && pendingRun) {
397
458
  runtime.clearPending()
398
459
  }
399
460
  return out
@@ -479,8 +540,8 @@ export function registerTools(ctx, runtime) {
479
540
  value.plan && value.plan.scan ? 'scan: ' + Object.keys(value.plan.scan).join(', ') : '',
480
541
  value.hard_limits ? 'hard limits: ' + JSON.stringify(value.hard_limits).slice(0, 240) : '',
481
542
  value.warnings && value.warnings.length ? 'warnings: ' + value.warnings.join('; ') : '',
482
- value.name_check && value.name_check.checked && value.name_check.missing && value.name_check.missing.length ? 'script key check: MISSING ' + value.name_check.missing.join(', ') : '',
483
- value.name_check && value.name_check.checked === false ? 'script key check: not checked (' + (value.name_check.reason || 'no script') + ')' : '',
543
+ nameCheckLine(value),
544
+ value.dropped_stale_pending ? 'dropped stale pending run: ' + value.dropped_stale_pending.run_folder + ' (' + value.dropped_stale_pending.age_s + ' s old, no shots ever appeared)' : '',
484
545
  value.engage ? 'submitted: ' + (value.engage.mapped_run_folder || value.engage.run_folder || '<run folder pending>') + ' n_shots_at_submit: ' + value.n_shots_at_submit : '',
485
546
  value.engage ? 'call labs_wait to finalize this protocol.' : '',
486
547
  ]))),
@@ -496,38 +557,39 @@ export function registerTools(ctx, runtime) {
496
557
  return { name: loaded.name, file: loaded.file, warnings: loaded.warnings.concat(planned.warnings || []), plan: planned.plan, hard_limits: planned.hard_limits, name_check: planned.name_check }
497
558
  }
498
559
  return runtime.serial(async () => {
499
- await clearStalePending(runtime, exec)
560
+ const dropped = await clearStalePending(runtime, exec)
500
561
  const planned = await runtime.runBridge('protocol_plan', { protocol: loaded.plan }, { signal: exec.signal })
501
562
  if (planned.hard_limits && planned.hard_limits.checked) {
502
563
  // plan command already fails on violations; nothing to do
503
564
  }
504
565
  const nameCheck = planned.name_check
505
- if (nameCheck && nameCheck.checked && nameCheck.missing && nameCheck.missing.length) {
506
- const detail = nameCheck.missing.map((key) => {
507
- const suggestions = (nameCheck.suggestions || {})[key] || []
508
- return key + (suggestions.length ? ' (closest: ' + suggestions.join(', ') + ')' : '')
509
- }).join(', ')
510
- await requireApproval(runtime, exec, 'labs_protocol', 'Protocol ' + loaded.name + ' uses key(s) that are not in the currently loaded labscript: ' + detail + '. Continue anyway?', 'protocolNames')
566
+ const missingDetail = nameCheckDetail(nameCheck)
567
+ if (missingDetail) {
568
+ await requireApproval(runtime, exec, 'labs_protocol', 'Protocol ' + loaded.name + ' uses key(s) that are not in the currently loaded labscript: ' + missingDetail + '. Continue anyway?', 'protocolNames')
511
569
  }
512
570
  await requireApproval(runtime, exec, 'labs_protocol', 'Run protocol ' + loaded.name + ': expected ' + planned.plan.expected_n_shots + ' shots, labscript ' + (loaded.plan.labscript || loaded.plan.joint_script) + ', backend ' + runtime.config.backend, 'protocolRun')
513
571
  const started = await runtime.runBridge('protocol_start', { protocol: loaded.plan, note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
514
572
  if (started.engage) {
515
573
  runtime.setLastRun({
516
- run_folder: started.engage.run_folder,
574
+ run_folder: started.engage.mapped_run_folder || started.engage.run_folder,
575
+ remote_run_folder: started.engage.run_folder,
517
576
  expected_n_shots: started.expected_n_shots,
518
577
  submitted_at: started.submitted_at,
519
578
  })
520
579
  runtime.setPending({
521
580
  kind: 'protocol',
522
581
  plan: loaded.plan,
523
- run_folder: started.engage.run_folder,
582
+ run_folder: started.engage.mapped_run_folder || started.engage.run_folder,
583
+ remote_run_folder: started.engage.run_folder,
524
584
  expected_n_shots: started.expected_n_shots,
525
585
  snapshot: started.snapshot,
526
586
  submitted_at: started.submitted_at,
527
587
  note: args.note,
528
588
  })
529
589
  }
530
- return Object.assign({ name: loaded.name, file: loaded.file, warnings: loaded.warnings.concat(planned.warnings || []), name_check: nameCheck }, started)
590
+ const out = Object.assign({ name: loaded.name, file: loaded.file, warnings: loaded.warnings.concat(planned.warnings || []), name_check: nameCheck }, started)
591
+ if (dropped) out.dropped_stale_pending = dropped
592
+ return out
531
593
  })
532
594
  },
533
595
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-labscript",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Control labscript-suite experiments from DeepSeek Harness: runmanager globals and scans, submits and waits, YAML fixed-operation protocols with postprocess write-back, panel snapshots, a built-in mock backend and a read-only doctor.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/python/bridge.py CHANGED
@@ -114,6 +114,23 @@ def shot_executed(path):
114
114
  except Exception:
115
115
  return None
116
116
 
117
+
118
+ def executed_prefix(shots):
119
+ """How many shot h5 files already hold acquisition data (binary search).
120
+
121
+ labscript compiles and submits shots in file order, so executed shots are a prefix
122
+ of the sorted file list. Opening a handful of files keeps this cheap even when the
123
+ run folder is a network share.
124
+ """
125
+ low, high = 0, len(shots)
126
+ while low < high:
127
+ middle = (low + high) // 2
128
+ if shot_executed(shots[middle]) is True:
129
+ low = middle + 1
130
+ else:
131
+ high = middle
132
+ return low
133
+
117
134
  # ---------------------------------------------------------------- scan expr
118
135
 
119
136
  _ALLOWED_CALLS = {"arange", "linspace", "array", "logspace"}
@@ -302,7 +319,11 @@ class LabscriptBackend(object):
302
319
  return dict(self.client().get_globals(raw=raw))
303
320
 
304
321
  def set_globals(self, values, raw=False):
305
- return dict(self.client().set_globals(values, raw=raw))
322
+ # runmanager's set_globals is a side-effect RPC: its handler returns None, so
323
+ # never feed the reply to dict(). Report what was requested; the caller reads
324
+ # the panel back to verify.
325
+ written = self.client().set_globals(values, raw=raw)
326
+ return dict(written) if isinstance(written, dict) else dict(values)
306
327
 
307
328
  def n_shots(self):
308
329
  return int(self.client().n_shots())
@@ -988,21 +1009,25 @@ def cmd_set(config, args):
988
1009
  snap = snapshot_panel(backend, config, "set", {"keys": sorted(values.keys()), "raw": raw, "note": args.get("note")}, touched=values.keys())
989
1010
  try:
990
1011
  written = backend.set_globals(values, raw=raw)
991
- except BridgeError as exc:
992
- detail = dict(exc.detail or {})
993
- detail["snapshot"] = snap
994
- detail["hint"] = "set_globals failed after the panel snapshot; the panel may be partially written. Use labs_restore to undo."
995
- raise BridgeError(exc.code, exc.message, detail)
996
1012
  except Exception as exc:
997
- raise BridgeError("SET_FAILED", "set_globals failed: " + type(exc).__name__ + ": " + str(exc), {
998
- "snapshot": snap,
999
- "hint": "the panel may be partially written; use labs_restore to undo",
1000
- })
1013
+ detail = dict(getattr(exc, "detail", None) or {})
1014
+ detail["snapshot"] = snap
1015
+ # A failed write can still have landed (runmanager writes each key in turn),
1016
+ # so report what the panel holds now instead of guessing in the hint.
1017
+ try:
1018
+ now = backend.get_globals(raw=True)
1019
+ detail["landed"] = dict((k, jsonable(now.get(k))) for k in values)
1020
+ except Exception:
1021
+ pass
1022
+ detail["hint"] = "the write may have landed partially or not at all: read the keys back with labs_status before retrying, and only labs_restore if they are wrong"
1023
+ raise BridgeError(getattr(exc, "code", None) or "SET_FAILED", "set_globals failed: " + type(exc).__name__ + ": " + str(exc), detail)
1001
1024
  readback = backend.get_globals(raw=False)
1002
1025
  raw_readback = backend.get_globals(raw=True)
1003
1026
  missing = [k for k in values if k not in readback]
1004
1027
  out = {
1005
- "written": jsonable(written),
1028
+ # Report what was asked for even if a backend returns something else (runmanager's
1029
+ # RPC returns nothing); the readback below carries the truth from the panel.
1030
+ "written": jsonable(written) if isinstance(written, dict) else dict((k, jsonable(v)) for k, v in values.items()),
1006
1031
  "readback": dict((k, jsonable(readback.get(k))) for k in values),
1007
1032
  "raw_readback": dict((k, jsonable(raw_readback.get(k))) for k in values),
1008
1033
  "missing": missing,
@@ -1050,24 +1075,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1050
1075
  poll = float(engage_cfg.get("pollS") or 1.0)
1051
1076
  settle = float(engage_cfg.get("settleS") or 2.0)
1052
1077
  stall = float(engage_cfg.get("stallS") or 20.0)
1078
+ # labscript writes each shot h5 before compiling it, so a healthy compilation keeps
1079
+ # adding files. When nothing has appeared for this long the compile almost certainly
1080
+ # aborted; returning a diagnosis beats blocking for the full timeout_s.
1081
+ no_shot = float(engage_cfg.get("noShotS") or 25.0)
1082
+ want = int(expected or 0)
1053
1083
  start = time.time()
1084
+ last_progress = start
1054
1085
  stable_since = None
1055
1086
  compiled_since = None
1056
1087
  last_count = -1
1057
1088
  last_size = -1
1058
- last_executed = None
1089
+ shots = []
1059
1090
 
1060
- def result(complete, timed_out, diagnosis, count, executed, elapsed, hint=None):
1091
+ def result(complete, timed_out, diagnosis, elapsed, hint=None):
1061
1092
  payload = {
1062
1093
  "run_folder": run_folder,
1063
1094
  "mapped_run_folder": folder,
1064
1095
  "folder_exists": os.path.isdir(folder),
1065
1096
  "expected_n_shots": expected,
1066
- "n_h5": count,
1097
+ "n_h5": len(shots),
1098
+ "n_executed": executed_prefix(shots),
1067
1099
  "complete": complete,
1068
1100
  "timed_out": timed_out,
1069
1101
  "diagnosis": diagnosis,
1070
- "executed": executed,
1102
+ "executed": shot_executed(shots[-1]) if shots else None,
1071
1103
  "elapsed": round(elapsed, 1),
1072
1104
  }
1073
1105
  if hint:
@@ -1078,8 +1110,10 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1078
1110
  shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True))
1079
1111
  count = len(shots)
1080
1112
  total = sum(os.path.getsize(p) for p in shots) if shots else 0
1113
+ if count != last_count or total != last_size:
1114
+ last_progress = time.time()
1081
1115
  ready = False
1082
- if count >= (expected or 0) and count == last_count and total == last_size and shots:
1116
+ if count >= (want or 0) and count == last_count and total == last_size and shots:
1083
1117
  ready = shot_ready(shots[-1])
1084
1118
  executed = None
1085
1119
  if ready:
@@ -1089,7 +1123,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1089
1123
  if stable_since is None:
1090
1124
  stable_since = time.time()
1091
1125
  elif time.time() - stable_since >= settle:
1092
- return result(True, False, "complete", count, True, time.time() - start)
1126
+ return result(True, False, "complete", time.time() - start)
1093
1127
  else:
1094
1128
  stable_since = None
1095
1129
  if ready and executed is False:
@@ -1097,19 +1131,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1097
1131
  compiled_since = time.time()
1098
1132
  elif time.time() - compiled_since >= stall:
1099
1133
  return result(
1100
- False, False, "compiled_not_executed", count, False, time.time() - start,
1134
+ False, False, "compiled_not_executed", time.time() - start,
1101
1135
  "shots were compiled but show no acquisition data after " + str(int(stall)) +
1102
1136
  " s: BLACS may be paused / the queue may not be running, or the run is still being programmed. Check BLACS and the panel, then labs_abort if needed.")
1103
1137
  else:
1104
1138
  compiled_since = None
1105
1139
  last_count = count
1106
1140
  last_size = total
1107
- last_executed = executed
1141
+ quiet = time.time() - last_progress
1142
+ if want and count < want and quiet >= no_shot:
1143
+ if count == 0:
1144
+ diagnosis = "no_shots"
1145
+ hint = ("no shot h5 appeared in " + str(int(no_shot)) + " s: the compilation probably aborted "
1146
+ "(runmanager shows the error) or BLACS is not running. Check runmanager, call labs_wait "
1147
+ "again to keep waiting, or labs_abort.")
1148
+ else:
1149
+ diagnosis = "compile_stalled"
1150
+ hint = ("compilation stopped at " + str(count) + " of " + str(want) + " shot h5 files and "
1151
+ "nothing changed for " + str(int(no_shot)) + " s: it probably aborted (runmanager shows "
1152
+ "the error). Check runmanager, call labs_wait again to keep waiting, or labs_abort.")
1153
+ return result(False, False, diagnosis, time.time() - start, hint)
1108
1154
  if time.time() - start >= timeout:
1109
1155
  if count == 0:
1110
1156
  diagnosis = "no_shots"
1111
1157
  hint = "no shot h5 appeared: check that the run was accepted, that BLACS is running, and that pathMap points at the run folder."
1112
- elif count < (expected or 0):
1158
+ elif count < want:
1113
1159
  diagnosis = "compiling"
1114
1160
  hint = "only " + str(count) + " of " + str(expected) + " shots appeared before the timeout; the run may still be compiling or blocked."
1115
1161
  elif executed is False:
@@ -1118,7 +1164,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1118
1164
  else:
1119
1165
  diagnosis = "partial"
1120
1166
  hint = "shots appeared but the run was not confirmed complete before the timeout."
1121
- return result(False, True, diagnosis, count, executed, time.time() - start, hint)
1167
+ return result(False, True, diagnosis, time.time() - start, hint)
1122
1168
  time.sleep(poll)
1123
1169
 
1124
1170
 
@@ -1146,7 +1192,20 @@ def cmd_progress(config, args):
1146
1192
  expected = int(args.get("expected_n_shots") or 0)
1147
1193
  folder = map_path(config, run_folder)
1148
1194
  shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True)) if folder else []
1149
- return {"run_folder": run_folder, "mapped_run_folder": folder, "folder_exists": bool(folder) and os.path.isdir(folder), "n_h5": len(shots), "expected_n_shots": expected, "complete": bool(expected and len(shots) >= expected)}
1195
+ # n_h5 counts files, which labscript writes before compiling each shot; n_executed
1196
+ # counts the shots that actually acquired data. A run is only complete when the
1197
+ # last shot has acquisition data, not merely when the file count matches.
1198
+ executed = executed_prefix(shots)
1199
+ complete = bool(expected and len(shots) >= expected and executed == len(shots))
1200
+ return {
1201
+ "run_folder": run_folder,
1202
+ "mapped_run_folder": folder,
1203
+ "folder_exists": bool(folder) and os.path.isdir(folder),
1204
+ "n_h5": len(shots),
1205
+ "n_executed": executed,
1206
+ "expected_n_shots": expected,
1207
+ "complete": complete,
1208
+ }
1150
1209
 
1151
1210
 
1152
1211
 
@@ -8,6 +8,7 @@ import os
8
8
  import shutil
9
9
  import sys
10
10
  import tempfile
11
+ import time
11
12
 
12
13
  HERE = os.path.dirname(os.path.abspath(__file__))
13
14
  sys.path.insert(0, os.path.dirname(HERE))
@@ -277,6 +278,159 @@ def test_wait_diagnosis_compiled_not_executed():
277
278
  shutil.rmtree(tmp, ignore_errors=True)
278
279
 
279
280
 
281
+ def write_shot(path, executed):
282
+ """Write a shot h5 like labscript does: globals first, acquisition data later."""
283
+ import h5py
284
+ with h5py.File(path, "w") as handle:
285
+ handle.create_group("globals")
286
+ if executed:
287
+ label = handle.create_group("images/camera/label")
288
+ label.create_dataset("frame", data=[1.0, 2.0])
289
+
290
+
291
+ def test_wait_reports_stalled_compilation_early():
292
+ """A compile that dies after one shot h5 must not block for the whole timeout."""
293
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-stalled-")
294
+ try:
295
+ cfg = make_config(tmp)
296
+ cfg["engage"] = {"timeoutS": 10, "pollS": 0.05, "settleS": 0.1, "noShotS": 0.3}
297
+ run_dir = os.path.join(tmp, "stalled_run")
298
+ os.makedirs(run_dir, exist_ok=True)
299
+ write_shot(os.path.join(run_dir, "shot_0000.h5"), executed=False)
300
+ started = time.time()
301
+ result = bridge.wait_for_run(cfg, run_dir, 64, 10)
302
+ elapsed = time.time() - started
303
+ assert result["diagnosis"] == "compile_stalled", result
304
+ assert result["timed_out"] is False, result
305
+ assert result["n_h5"] == 1, result
306
+ assert result["n_executed"] == 0, result
307
+ assert elapsed < 3.0, elapsed
308
+ assert "aborted" in result["hint"], result
309
+ finally:
310
+ shutil.rmtree(tmp, ignore_errors=True)
311
+
312
+
313
+ def test_wait_reports_no_shots_early():
314
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-noshots-")
315
+ try:
316
+ cfg = make_config(tmp)
317
+ cfg["engage"] = {"timeoutS": 10, "pollS": 0.05, "settleS": 0.1, "noShotS": 0.3}
318
+ run_dir = os.path.join(tmp, "empty_run")
319
+ os.makedirs(run_dir, exist_ok=True)
320
+ started = time.time()
321
+ result = bridge.wait_for_run(cfg, run_dir, 64, 10)
322
+ elapsed = time.time() - started
323
+ assert result["diagnosis"] == "no_shots", result
324
+ assert result["timed_out"] is False, result
325
+ assert result["n_h5"] == 0 and result["n_executed"] == 0, result
326
+ assert elapsed < 3.0, elapsed
327
+ finally:
328
+ shutil.rmtree(tmp, ignore_errors=True)
329
+
330
+
331
+ def test_progress_separates_files_from_executed_shots():
332
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-progress-exec-")
333
+ try:
334
+ cfg = make_config(tmp)
335
+ compiled = os.path.join(tmp, "compiled_run")
336
+ os.makedirs(compiled, exist_ok=True)
337
+ write_shot(os.path.join(compiled, "shot_0000.h5"), executed=True)
338
+ write_shot(os.path.join(compiled, "shot_0001.h5"), executed=False)
339
+ write_shot(os.path.join(compiled, "shot_0002.h5"), executed=False)
340
+ out = bridge.cmd_progress(cfg, {"run_folder": compiled, "expected_n_shots": 3})
341
+ assert out["n_h5"] == 3, out
342
+ assert out["n_executed"] == 1, out
343
+ assert out["complete"] is False, out
344
+ done = os.path.join(tmp, "done_run")
345
+ os.makedirs(done, exist_ok=True)
346
+ for index in range(2):
347
+ write_shot(os.path.join(done, "shot_000%d.h5" % index), executed=True)
348
+ finished = bridge.cmd_progress(cfg, {"run_folder": done, "expected_n_shots": 2})
349
+ assert finished["complete"] is True, finished
350
+ assert finished["n_executed"] == 2, finished
351
+ finally:
352
+ shutil.rmtree(tmp, ignore_errors=True)
353
+
354
+
355
+ def test_real_backend_set_globals_survives_none_reply():
356
+ """runmanager's set_globals returns None; the write must still be reported."""
357
+ class FakeClient(object):
358
+ def __init__(self):
359
+ self.calls = []
360
+
361
+ def set_globals(self, values, raw=False):
362
+ self.calls.append((dict(values), raw))
363
+ return None
364
+
365
+ client = FakeClient()
366
+ backend = bridge.LabscriptBackend({})
367
+ backend._client = client
368
+ written = backend.set_globals({"do_acquire": True, "amplitude": 0.5})
369
+ assert written == {"do_acquire": True, "amplitude": 0.5}, written
370
+ assert client.calls == [({"do_acquire": True, "amplitude": 0.5}, False)], client.calls
371
+
372
+
373
+ def test_cmd_set_accepts_backend_that_returns_none():
374
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-set-none-")
375
+ try:
376
+ cfg = make_config(tmp)
377
+ original = bridge.make_backend
378
+
379
+ def make(config):
380
+ backend = bridge.MockBackend(config)
381
+
382
+ def set_globals(values, raw=False):
383
+ bridge.MockBackend.set_globals(backend, values, raw=raw)
384
+ return None
385
+
386
+ backend.set_globals = set_globals
387
+ return backend
388
+
389
+ bridge.make_backend = make
390
+ try:
391
+ out = bridge.cmd_set(cfg, {"values": {"amplitude": 0.71}, "note": "None-returning client"})
392
+ finally:
393
+ bridge.make_backend = original
394
+ assert out["missing"] == [], out
395
+ assert out["readback"]["amplitude"] == 0.71, out
396
+ assert out["written"] == {"amplitude": 0.71}, out
397
+ finally:
398
+ shutil.rmtree(tmp, ignore_errors=True)
399
+
400
+
401
+ def test_cmd_set_failure_reports_what_landed():
402
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-set-partial-")
403
+ try:
404
+ cfg = make_config(tmp)
405
+ original = bridge.make_backend
406
+
407
+ def make(config):
408
+ backend = bridge.MockBackend(config)
409
+
410
+ def set_globals(values, raw=False):
411
+ if "amplitude" in values:
412
+ bridge.MockBackend.set_globals(backend, {"amplitude": values["amplitude"]}, raw=raw)
413
+ raise ValueError("Global nope not found in any active group")
414
+
415
+ backend.set_globals = set_globals
416
+ return backend
417
+
418
+ bridge.make_backend = make
419
+ try:
420
+ bridge.cmd_set(cfg, {"values": {"amplitude": 0.71, "nope": 1}})
421
+ except bridge.BridgeError as exc:
422
+ assert exc.code == "SET_FAILED", exc.code
423
+ assert "not found" in exc.message, exc.message
424
+ assert exc.detail["snapshot"]["name"].endswith(".json"), exc.detail
425
+ assert exc.detail["landed"]["amplitude"] == "0.71", exc.detail
426
+ assert "labs_status" in exc.detail["hint"], exc.detail
427
+ else:
428
+ raise AssertionError("expected SET_FAILED")
429
+ finally:
430
+ bridge.make_backend = original
431
+ shutil.rmtree(tmp, ignore_errors=True)
432
+
433
+
280
434
  def test_doctor_mock():
281
435
  tmp = tempfile.mkdtemp(prefix="dsh-labscript-doctor-")
282
436
  try: