dsh-labscript 0.2.6 → 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,51 @@
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
+
34
+ ## 0.2.7
35
+
36
+ - Fix: labs_wait no longer fails with "value is not lossless JSON". Tool results must be
37
+ pure JSON; a completed wait could carry an undefined hint field, which the DSH host
38
+ rejects when recording the result (the run itself was fine). labs_status pending
39
+ progress and the aborted-wait result are cleaned the same way.
40
+ - Fix: non-ASCII arguments (e.g. a Chinese note) no longer break the bridge. Python's
41
+ stdin on Windows uses the locale encoding (GBK); the bridge now reads stdin as UTF-8.
42
+ Such a note used to submit the run and then fail with UnicodeEncodeError in the event
43
+ log, which made a successful engage look failed.
44
+ - Fix: a failed event-log write can no longer turn a successful action into an error; it
45
+ is reported on stderr and the action result is returned as usual.
46
+ - Tests: Unicode note end-to-end through the bridge process, event-log failure
47
+ isolation, and a lossless-JSON check on tool results.
48
+
3
49
  ## 0.2.6
4
50
 
5
51
  - Fix: labs_data rendered only counts. It now prints selected first-shot globals
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, 39 tests (fake bridge, no python needed)
129
- python python/tests/run_tests.py # 20 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 []
@@ -133,6 +154,7 @@ async function pendingProgress(runtime, exec) {
133
154
  note: pending.note,
134
155
  elapsed_s: pending.submitted_at ? Math.round((Date.now() - Date.parse(pending.submitted_at)) / 1000) : undefined,
135
156
  }
157
+ for (const key of Object.keys(base)) if (base[key] === undefined) delete base[key]
136
158
  if (!pending.run_folder) return base
137
159
  try {
138
160
  const progress = await runtime.runBridge('progress', {
@@ -165,23 +187,57 @@ async function requireApproval(runtime, exec, toolName, reason, approvalKey) {
165
187
  }
166
188
  }
167
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.
168
203
  async function clearStalePending(runtime, exec) {
169
204
  const pending = runtime.state.pending
170
- if (!pending) return
205
+ if (!pending) return null
171
206
  if (pending.run_folder) {
207
+ let progress = null
172
208
  try {
173
- const progress = await runtime.runBridge('progress', {
209
+ progress = await runtime.runBridge('progress', {
174
210
  run_folder: pending.run_folder,
175
211
  expected_n_shots: pending.expected_n_shots,
176
212
  }, { signal: exec.signal })
177
- if (!progress.complete) {
178
- 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
179
231
  }
180
- } catch (error) {
181
- 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)
182
237
  }
183
238
  }
184
239
  runtime.clearPending()
240
+ return null
185
241
  }
186
242
 
187
243
  function mockApprovalSkipped(runtime) {
@@ -226,13 +282,13 @@ export function registerTools(ctx, runtime) {
226
282
 
227
283
  ctx.tools.register({
228
284
  name: 'labs_set',
229
- 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.',
230
286
  parameters: {
231
287
  type: 'object',
232
288
  properties: {
233
289
  values: { type: 'object', additionalProperties: true, description: 'Panel globals to write: key -> scalar' },
234
- raw: { type: 'boolean', description: 'values are raw expression strings (e.g. np.arange(0, 8, 0.3))' },
235
- 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)' },
236
292
  note: { type: 'string', description: 'One sentence: what this batch is for (written to the event log)' },
237
293
  },
238
294
  required: ['values'],
@@ -256,7 +312,7 @@ export function registerTools(ctx, runtime) {
256
312
 
257
313
  ctx.tools.register({
258
314
  name: 'labs_engage',
259
- 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.',
260
316
  parameters: {
261
317
  type: 'object',
262
318
  properties: {
@@ -266,29 +322,33 @@ export function registerTools(ctx, runtime) {
266
322
  },
267
323
  isConcurrencySafe: () => false,
268
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)' : '',
269
326
  'submitted run: ' + (value.mapped_run_folder || value.run_folder || '<run folder pending>'),
270
327
  'n_shots_at_submit: ' + value.n_shots_at_submit + ' expected: ' + value.expected_n_shots,
271
328
  'call labs_wait when results are needed.',
272
329
  ]))),
273
330
  async execute(args, exec) {
274
331
  return runtime.serial(async () => {
275
- await clearStalePending(runtime, exec)
332
+ const dropped = await clearStalePending(runtime, exec)
276
333
  const before = await runtime.runBridge('status', {}, { signal: exec.signal })
277
334
  await requireApproval(runtime, exec, 'labs_engage', 'Submit a labscript run: ' + (before.labscript || '<script>') + ', n_shots=' + before.n_shots + ', backend=' + before.backend, 'engage')
278
335
  const data = await runtime.runBridge('engage', { note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
279
336
  runtime.setLastRun({
280
- run_folder: data.run_folder,
337
+ run_folder: data.mapped_run_folder || data.run_folder,
338
+ remote_run_folder: data.run_folder,
281
339
  expected_n_shots: data.expected_n_shots,
282
340
  submitted_at: data.submitted_at,
283
341
  })
284
342
  runtime.setPending({
285
343
  kind: 'engage',
286
- run_folder: data.run_folder,
344
+ run_folder: data.mapped_run_folder || data.run_folder,
345
+ remote_run_folder: data.run_folder,
287
346
  expected_n_shots: data.expected_n_shots,
288
347
  submitted_at: data.submitted_at,
289
348
  note: args.note,
290
349
  snapshot: null,
291
350
  })
351
+ if (dropped) data.dropped_stale_pending = dropped
292
352
  return data
293
353
  })
294
354
  },
@@ -296,7 +356,7 @@ export function registerTools(ctx, runtime) {
296
356
 
297
357
  ctx.tools.register({
298
358
  name: 'labs_wait',
299
- 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.',
300
360
  parameters: {
301
361
  type: 'object',
302
362
  properties: {
@@ -311,7 +371,7 @@ export function registerTools(ctx, runtime) {
311
371
  'run: ' + (value.mapped_run_folder || value.run_folder),
312
372
  value.aborted ? 'wait cancelled by labs_abort (run aborted)' : '',
313
373
  value.folder_exists === false ? 'folder: missing' : '',
314
- '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 : ''),
315
375
  value.hint ? 'hint: ' + value.hint : '',
316
376
  value.finalize && value.finalize.postprocess ? 'postprocess: ' + JSON.stringify(value.finalize.postprocess).slice(0, 400) : '',
317
377
  value.finalize && value.finalize.finish ? 'finish: ' + JSON.stringify(value.finalize.finish).slice(0, 300) : '',
@@ -322,8 +382,9 @@ export function registerTools(ctx, runtime) {
322
382
  const pending = runtime.state.pending
323
383
  const runFolder = args.run_folder || (pending ? pending.run_folder : '')
324
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')
325
- const expected = pending && pending.run_folder === runFolder ? pending.expected_n_shots : undefined
326
- if (!(pending && pending.run_folder === runFolder)) {
385
+ const pendingRun = isPendingRun(pending, runFolder)
386
+ const expected = pendingRun ? pending.expected_n_shots : undefined
387
+ if (!pendingRun) {
327
388
  let probe = null
328
389
  try {
329
390
  probe = await runtime.runBridge('progress', {
@@ -350,7 +411,7 @@ export function registerTools(ctx, runtime) {
350
411
  runtime.state.waitCancelledByAbort = false
351
412
  return {
352
413
  run_folder: runFolder,
353
- expected_n_shots: expected,
414
+ ...(expected !== undefined ? { expected_n_shots: expected } : {}),
354
415
  complete: false,
355
416
  timed_out: false,
356
417
  aborted: true,
@@ -367,6 +428,7 @@ export function registerTools(ctx, runtime) {
367
428
  run_folder: waited.run_folder,
368
429
  expected_n_shots: waited.expected_n_shots,
369
430
  n_h5: waited.n_h5,
431
+ n_executed: waited.n_executed,
370
432
  complete: waited.complete,
371
433
  timed_out: waited.timed_out,
372
434
  elapsed: waited.elapsed,
@@ -376,13 +438,14 @@ export function registerTools(ctx, runtime) {
376
438
  mapped_run_folder: waited.mapped_run_folder,
377
439
  folder_exists: waited.folder_exists,
378
440
  }
379
- if (waited.complete && runtime.state.lastRun && runtime.state.lastRun.run_folder === runFolder) {
441
+ for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key]
442
+ if (waited.complete && runtime.state.lastRun && isPendingRun(runtime.state.lastRun, runFolder)) {
380
443
  runtime.setLastRun(Object.assign({}, runtime.state.lastRun, {
381
444
  completed_at: new Date().toISOString(),
382
445
  n_h5: waited.n_h5,
383
446
  }))
384
447
  }
385
- if (waited.complete && pending && pending.kind === 'protocol' && pending.run_folder === runFolder) {
448
+ if (waited.complete && pending && pending.kind === 'protocol' && pendingRun) {
386
449
  const finalize = await runtime.runBridge('protocol_finalize', {
387
450
  protocol: pending.plan,
388
451
  run_folder: runFolder,
@@ -391,7 +454,7 @@ export function registerTools(ctx, runtime) {
391
454
  }, { signal: exec.signal, timeoutMs: 180000 })
392
455
  out.finalize = finalize
393
456
  runtime.clearPending()
394
- } else if (waited.complete && pending && pending.run_folder === runFolder) {
457
+ } else if (waited.complete && pending && pendingRun) {
395
458
  runtime.clearPending()
396
459
  }
397
460
  return out
@@ -477,8 +540,8 @@ export function registerTools(ctx, runtime) {
477
540
  value.plan && value.plan.scan ? 'scan: ' + Object.keys(value.plan.scan).join(', ') : '',
478
541
  value.hard_limits ? 'hard limits: ' + JSON.stringify(value.hard_limits).slice(0, 240) : '',
479
542
  value.warnings && value.warnings.length ? 'warnings: ' + value.warnings.join('; ') : '',
480
- 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(', ') : '',
481
- 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)' : '',
482
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 : '',
483
546
  value.engage ? 'call labs_wait to finalize this protocol.' : '',
484
547
  ]))),
@@ -494,38 +557,39 @@ export function registerTools(ctx, runtime) {
494
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 }
495
558
  }
496
559
  return runtime.serial(async () => {
497
- await clearStalePending(runtime, exec)
560
+ const dropped = await clearStalePending(runtime, exec)
498
561
  const planned = await runtime.runBridge('protocol_plan', { protocol: loaded.plan }, { signal: exec.signal })
499
562
  if (planned.hard_limits && planned.hard_limits.checked) {
500
563
  // plan command already fails on violations; nothing to do
501
564
  }
502
565
  const nameCheck = planned.name_check
503
- if (nameCheck && nameCheck.checked && nameCheck.missing && nameCheck.missing.length) {
504
- const detail = nameCheck.missing.map((key) => {
505
- const suggestions = (nameCheck.suggestions || {})[key] || []
506
- return key + (suggestions.length ? ' (closest: ' + suggestions.join(', ') + ')' : '')
507
- }).join(', ')
508
- 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')
509
569
  }
510
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')
511
571
  const started = await runtime.runBridge('protocol_start', { protocol: loaded.plan, note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
512
572
  if (started.engage) {
513
573
  runtime.setLastRun({
514
- 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,
515
576
  expected_n_shots: started.expected_n_shots,
516
577
  submitted_at: started.submitted_at,
517
578
  })
518
579
  runtime.setPending({
519
580
  kind: 'protocol',
520
581
  plan: loaded.plan,
521
- 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,
522
584
  expected_n_shots: started.expected_n_shots,
523
585
  snapshot: started.snapshot,
524
586
  submitted_at: started.submitted_at,
525
587
  note: args.note,
526
588
  })
527
589
  }
528
- 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
529
593
  })
530
594
  },
531
595
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-labscript",
3
- "version": "0.2.6",
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())
@@ -612,10 +633,13 @@ def snapshot_panel(backend, config, tool, args, touched=None):
612
633
 
613
634
 
614
635
  def append_event(config, event):
615
- folder = history_dir(config)
616
- path = os.path.join(folder, "events.jsonl")
617
- with open(path, "a", encoding="utf-8") as handle:
618
- handle.write(json.dumps(jsonable(event), ensure_ascii=False) + "\n")
636
+ try:
637
+ folder = history_dir(config)
638
+ path = os.path.join(folder, "events.jsonl")
639
+ with open(path, "a", encoding="utf-8") as handle:
640
+ handle.write(json.dumps(jsonable(event), ensure_ascii=False) + "\n")
641
+ except Exception as exc:
642
+ sys.stderr.write("append_event failed: %s\n" % (exc,))
619
643
 
620
644
 
621
645
  def load_snapshot(config, name):
@@ -985,21 +1009,25 @@ def cmd_set(config, args):
985
1009
  snap = snapshot_panel(backend, config, "set", {"keys": sorted(values.keys()), "raw": raw, "note": args.get("note")}, touched=values.keys())
986
1010
  try:
987
1011
  written = backend.set_globals(values, raw=raw)
988
- except BridgeError as exc:
989
- detail = dict(exc.detail or {})
990
- detail["snapshot"] = snap
991
- detail["hint"] = "set_globals failed after the panel snapshot; the panel may be partially written. Use labs_restore to undo."
992
- raise BridgeError(exc.code, exc.message, detail)
993
1012
  except Exception as exc:
994
- raise BridgeError("SET_FAILED", "set_globals failed: " + type(exc).__name__ + ": " + str(exc), {
995
- "snapshot": snap,
996
- "hint": "the panel may be partially written; use labs_restore to undo",
997
- })
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)
998
1024
  readback = backend.get_globals(raw=False)
999
1025
  raw_readback = backend.get_globals(raw=True)
1000
1026
  missing = [k for k in values if k not in readback]
1001
1027
  out = {
1002
- "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()),
1003
1031
  "readback": dict((k, jsonable(readback.get(k))) for k in values),
1004
1032
  "raw_readback": dict((k, jsonable(raw_readback.get(k))) for k in values),
1005
1033
  "missing": missing,
@@ -1047,24 +1075,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1047
1075
  poll = float(engage_cfg.get("pollS") or 1.0)
1048
1076
  settle = float(engage_cfg.get("settleS") or 2.0)
1049
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)
1050
1083
  start = time.time()
1084
+ last_progress = start
1051
1085
  stable_since = None
1052
1086
  compiled_since = None
1053
1087
  last_count = -1
1054
1088
  last_size = -1
1055
- last_executed = None
1089
+ shots = []
1056
1090
 
1057
- def result(complete, timed_out, diagnosis, count, executed, elapsed, hint=None):
1091
+ def result(complete, timed_out, diagnosis, elapsed, hint=None):
1058
1092
  payload = {
1059
1093
  "run_folder": run_folder,
1060
1094
  "mapped_run_folder": folder,
1061
1095
  "folder_exists": os.path.isdir(folder),
1062
1096
  "expected_n_shots": expected,
1063
- "n_h5": count,
1097
+ "n_h5": len(shots),
1098
+ "n_executed": executed_prefix(shots),
1064
1099
  "complete": complete,
1065
1100
  "timed_out": timed_out,
1066
1101
  "diagnosis": diagnosis,
1067
- "executed": executed,
1102
+ "executed": shot_executed(shots[-1]) if shots else None,
1068
1103
  "elapsed": round(elapsed, 1),
1069
1104
  }
1070
1105
  if hint:
@@ -1075,8 +1110,10 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1075
1110
  shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True))
1076
1111
  count = len(shots)
1077
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()
1078
1115
  ready = False
1079
- 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:
1080
1117
  ready = shot_ready(shots[-1])
1081
1118
  executed = None
1082
1119
  if ready:
@@ -1086,7 +1123,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1086
1123
  if stable_since is None:
1087
1124
  stable_since = time.time()
1088
1125
  elif time.time() - stable_since >= settle:
1089
- return result(True, False, "complete", count, True, time.time() - start)
1126
+ return result(True, False, "complete", time.time() - start)
1090
1127
  else:
1091
1128
  stable_since = None
1092
1129
  if ready and executed is False:
@@ -1094,19 +1131,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1094
1131
  compiled_since = time.time()
1095
1132
  elif time.time() - compiled_since >= stall:
1096
1133
  return result(
1097
- False, False, "compiled_not_executed", count, False, time.time() - start,
1134
+ False, False, "compiled_not_executed", time.time() - start,
1098
1135
  "shots were compiled but show no acquisition data after " + str(int(stall)) +
1099
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.")
1100
1137
  else:
1101
1138
  compiled_since = None
1102
1139
  last_count = count
1103
1140
  last_size = total
1104
- 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)
1105
1154
  if time.time() - start >= timeout:
1106
1155
  if count == 0:
1107
1156
  diagnosis = "no_shots"
1108
1157
  hint = "no shot h5 appeared: check that the run was accepted, that BLACS is running, and that pathMap points at the run folder."
1109
- elif count < (expected or 0):
1158
+ elif count < want:
1110
1159
  diagnosis = "compiling"
1111
1160
  hint = "only " + str(count) + " of " + str(expected) + " shots appeared before the timeout; the run may still be compiling or blocked."
1112
1161
  elif executed is False:
@@ -1115,7 +1164,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1115
1164
  else:
1116
1165
  diagnosis = "partial"
1117
1166
  hint = "shots appeared but the run was not confirmed complete before the timeout."
1118
- return result(False, True, diagnosis, count, executed, time.time() - start, hint)
1167
+ return result(False, True, diagnosis, time.time() - start, hint)
1119
1168
  time.sleep(poll)
1120
1169
 
1121
1170
 
@@ -1143,7 +1192,20 @@ def cmd_progress(config, args):
1143
1192
  expected = int(args.get("expected_n_shots") or 0)
1144
1193
  folder = map_path(config, run_folder)
1145
1194
  shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True)) if folder else []
1146
- 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
+ }
1147
1209
 
1148
1210
 
1149
1211
 
@@ -1530,6 +1592,10 @@ HANDLERS = {
1530
1592
 
1531
1593
 
1532
1594
  def main():
1595
+ try:
1596
+ sys.stdin.reconfigure(encoding="utf-8")
1597
+ except Exception:
1598
+ pass
1533
1599
  try:
1534
1600
  request = json.load(sys.stdin)
1535
1601
  except Exception as exc:
@@ -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:
@@ -432,6 +586,41 @@ def test_data_keys_and_scan_summary():
432
586
  shutil.rmtree(tmp, ignore_errors=True)
433
587
 
434
588
 
589
+ def test_engage_with_unicode_note():
590
+ import subprocess
591
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-unicode-")
592
+ try:
593
+ cfg = make_config(tmp)
594
+ request = {"cmd": "engage", "config": cfg, "args": {"note": "中文备注:测试"}}
595
+ env = dict((k, v) for k, v in os.environ.items() if k not in ("PYTHONUTF8", "PYTHONIOENCODING"))
596
+ proc = subprocess.run(
597
+ [sys.executable, os.path.join(os.path.dirname(HERE), "bridge.py")],
598
+ input=json.dumps(request).encode("utf-8"),
599
+ stdout=subprocess.PIPE,
600
+ stderr=subprocess.PIPE,
601
+ env=env,
602
+ )
603
+ payload = json.loads(proc.stdout.decode("utf-8"))
604
+ assert payload.get("ok") is True, payload
605
+ assert payload["data"]["run_folder"], payload["data"]
606
+ with open(os.path.join(tmp, "history", "events.jsonl"), "r", encoding="utf-8") as handle:
607
+ assert "中文备注" in handle.read()
608
+ finally:
609
+ shutil.rmtree(tmp, ignore_errors=True)
610
+
611
+
612
+ def test_append_event_never_raises():
613
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-events-")
614
+ try:
615
+ blocked = os.path.join(tmp, "blocked")
616
+ with open(blocked, "w", encoding="utf-8") as handle:
617
+ handle.write("not a directory")
618
+ cfg = {"panelHistoryDir": os.path.join(blocked, "history")}
619
+ bridge.append_event(cfg, {"tool": "x", "note": "中文"})
620
+ finally:
621
+ shutil.rmtree(tmp, ignore_errors=True)
622
+
623
+
435
624
  def main():
436
625
  tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
437
626
  failures = 0