@stravica-ai/digital-operator-pa 0.1.69 → 0.1.71

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.
@@ -1,2 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import('../src/pa/cli.mjs').then(async (m) => process.exit(await m.main(process.argv.slice(2))));
2
+ // Each verb returns its exit code; the process ends only once stdout and
3
+ // stderr have taken every byte. A write to a pipe is asynchronous on some
4
+ // platforms, so a process.exit straight after main returned cut a long
5
+ // output at the pipe's buffer (a board piped to `wc -c` gave 65536 bytes).
6
+ // An empty write's callback runs after every write queued before it, and
7
+ // process.exit then still ends a verb that left a handle open.
8
+ import('../src/pa/cli.mjs').then(async (m) => {
9
+ const code = await m.main(process.argv.slice(2));
10
+ process.exitCode = code;
11
+ let open = 2;
12
+ const flushed = () => { open -= 1; if (open === 0) process.exit(code); };
13
+ for (const stream of [process.stdout, process.stderr]) {
14
+ // A reader that closes early (EPIPE) must not turn into a crash.
15
+ stream.on('error', () => {});
16
+ let called = false;
17
+ stream.write('', () => { if (!called) { called = true; flushed(); } });
18
+ }
19
+ });
@@ -6,13 +6,16 @@
6
6
  // when the instance keeps initiatives records, with the open questions under
7
7
  // initiatives/<id>/questions/. It writes nothing anywhere: no cache, no lock.
8
8
  // It reads the spawn-log's lock file (agents/<agent>/spawn-log.json.lock) and
9
- // never writes, breaks or sweeps it.
9
+ // the board's lock file (the board path plus .lock) and never writes, breaks
10
+ // or sweeps either.
10
11
  //
11
12
  // One problem line each for:
12
13
  // - the board's updatedAt 90 minutes old or more (the Status page's red
13
14
  // threshold) while a dispatch is open, or no board at all while one is;
14
- // - an open dispatch with no board item (an item in groups[].items[] whose
15
- // ids holds the dispatch id; a mention in a title does not count);
15
+ // - an open dispatch with no board item (an item in groups[].items[], or
16
+ // on a schema v2 board a lane's running item or one of its queued items,
17
+ // any lane, paused or not, whose ids holds the dispatch id; a mention in
18
+ // a title does not count, nor an id held only by onMe, forMe or done);
16
19
  // - an open dispatch that has been open 24 to 48 hours (close it or check it);
17
20
  // - the spawn-log lock held for longer than the stale window
18
21
  // (LOCK_STALE_AFTER_MS, one minute): the line leads with
@@ -31,6 +34,14 @@
31
34
  // `unrecognised status` warnings: the human output gives one line per word
32
35
  // with its count and at most five ids, `--json` lists every one. They never
33
36
  // change the exit code.
37
+ // A board lock held by a live pid on this host whose lock file is more than
38
+ // BOARD_LOCK_LONG_HELD_MS (10 minutes) old by the check's clock is one more
39
+ // warning: one line naming the board, the pid, the age and the manual clear
40
+ // (`rm <lock path>` once the pid is checked not to be a board writer), the
41
+ // `boardLockWarning` object in `--json` (present only when it fires) and a
42
+ // warning line in `--hook-envelope`. Such a lock is never broken, by the
43
+ // check or by a writer. A foreign-host or ownerless board lock gives no
44
+ // warning: the next writer breaks it as stale after BOARD_LOCK_STALE_MS.
34
45
  //
35
46
  // Exit codes: 0 clean | 1 usage error | 2 one or more problems |
36
47
  // 3 the check could not run (no repo root, the spawn-log or the board
@@ -60,11 +71,15 @@ import {
60
71
 
61
72
  import { getRepoRoot } from './repo-root.mjs';
62
73
  import {
63
- boardPath,
74
+ BOARD_LOCK_LONG_HELD_MS,
75
+ BOARD_LOCK_WAIT_MS,
64
76
  boardRelPath,
77
+ formatLockAge,
78
+ inspectBoardLock,
79
+ isLongHeldBoardLock,
65
80
  isValidAgentName,
66
81
  readBoard,
67
- resolveAgentName,
82
+ resolveAgentBoard,
68
83
  } from './status-board.mjs';
69
84
 
70
85
  export const BOARD_STALE_MINUTES = 90;
@@ -79,7 +94,8 @@ Usage:
79
94
 
80
95
  Reports, one line each:
81
96
  - the board not updated for ${BOARD_STALE_MINUTES} minutes (or missing) while a dispatch is open
82
- - an open dispatch with no board item (an item whose ids hold the dispatch id)
97
+ - an open dispatch with no board item (a group item, or a lane's running or
98
+ queued item, whose ids hold the dispatch id)
83
99
  - a dispatch open 24 to 48 hours (close it or check it)
84
100
  - the spawn-log lock held for more than ${LOCK_STALE_AFTER_MS / 60000} minute, naming its holder and
85
101
  pa dispatch unlock (writers wait on it and then fail)
@@ -87,6 +103,9 @@ Reports, one line each:
87
103
  dispatch with no stage, and an open question with no on-me entry
88
104
  Unrecognised status or outcome words are warnings (exit 0): one line per word,
89
105
  with its count and at most 5 ids; --json lists every one.
106
+ A board lock held by a live process on this host for more than
107
+ ${BOARD_LOCK_LONG_HELD_MS / 60000} minutes is a warning (exit 0) naming its pid, its age and the
108
+ manual clear; nothing breaks it.
90
109
 
91
110
  Flags:
92
111
  --json One JSON object on stdout
@@ -94,7 +113,8 @@ Flags:
94
113
  --agent <name> The agent folder (default: preferences paName, else the
95
114
  single agents/* folder holding next-session.md)
96
115
 
97
- Read-only: never writes a file, and never breaks or removes the spawn-log lock.
116
+ Read-only: never writes a file, and never breaks or removes the spawn-log lock
117
+ or the board lock.
98
118
 
99
119
  Exit codes:
100
120
  0 clean | 1 usage error | 2 problems found | 3 the check could not run
@@ -173,6 +193,11 @@ function boardIdSet(board, key) {
173
193
  };
174
194
  if (key === 'groups') {
175
195
  for (const g of Array.isArray(board.groups) ? board.groups : []) if (isPlainObject(g)) add(g.items);
196
+ for (const l of Array.isArray(board.lanes) ? board.lanes : []) {
197
+ if (!isPlainObject(l)) continue;
198
+ add([l.running]);
199
+ add(l.queued);
200
+ }
176
201
  } else {
177
202
  add(board.onMe);
178
203
  }
@@ -191,6 +216,9 @@ function boardIdSet(board, key) {
191
216
  * @param {object|null} [input.lock] the spawn-log lock as read (readSpawnLogLock),
192
217
  * or null/absent when there is none or it was not read
193
218
  * @param {string} [input.lockRelPath] the lock's path for the message
219
+ * @param {object|null} [input.boardLock] the board lock as inspected (inspectBoardLock),
220
+ * or null/absent when there is none or it was not read
221
+ * @param {string} [input.boardRelPath] the board's path for the board-lock warning
194
222
  * @param {Date|number|Function} [input.now]
195
223
  */
196
224
  export function checkBoard(input = {}) {
@@ -303,6 +331,31 @@ export function checkBoard(input = {}) {
303
331
  openNotOnBoard,
304
332
  problems,
305
333
  unrecognisedStatuses,
334
+ boardLockWarning: boardLockWarning(input.boardLock, input.boardRelPath, nowMs),
335
+ };
336
+ }
337
+
338
+ /**
339
+ * The board-lock warning, or null: a lock held by a live pid on this host
340
+ * whose file is more than BOARD_LOCK_LONG_HELD_MS old at nowMs. The message
341
+ * names the board, the pid, the age and the manual clear, and says nothing
342
+ * breaks it. A warning: it never changes the exit code.
343
+ */
344
+ export function boardLockWarning(lock, boardRel, nowMs) {
345
+ if (!isPlainObject(lock) || !isLongHeldBoardLock(lock, nowMs)) return null;
346
+ const board = typeof boardRel === 'string' && boardRel ? boardRel : 'the status board';
347
+ const lockPath = board === 'the status board' ? 'the status board lock' : board + '.lock';
348
+ const ageMs = nowMs - lock.mtimeMs;
349
+ const age = formatLockAge(ageMs);
350
+ return {
351
+ kind: 'board-lock-held',
352
+ boardPath: board,
353
+ lockPath,
354
+ pid: lock.pid,
355
+ ageMinutes: Math.floor(ageMs / 60000),
356
+ message: 'board lock on ' + board + ' held by live pid ' + lock.pid + ' for ' + age
357
+ + '; writers wait ' + (BOARD_LOCK_WAIT_MS / 1000) + ' s, then fail, and nothing breaks it.'
358
+ + ' Once you have checked pid ' + lock.pid + ' is not a board writer, clear it with `rm ' + lockPath + '`',
306
359
  };
307
360
  }
308
361
 
@@ -351,7 +404,7 @@ function summaryLine(agent, result) {
351
404
  const live = result.open.live.length;
352
405
  const stale = result.open.stale.length;
353
406
  const nProblems = result.problems.length;
354
- const nWarnings = result.unrecognisedStatuses.length;
407
+ const nWarnings = result.unrecognisedStatuses.length + (result.boardLockWarning ? 1 : 0);
355
408
  const head = nProblems === 0
356
409
  ? 'board check: clean for ' + agent
357
410
  : 'board check: ' + plural(nProblems, 'problem', 'problems') + ' for ' + agent;
@@ -395,6 +448,7 @@ export function warningLines(unrecognisedStatuses) {
395
448
  export function renderHuman(agent, result) {
396
449
  const lines = [];
397
450
  for (const p of result.problems) lines.push('problem: ' + p.message);
451
+ if (result.boardLockWarning) lines.push('warning: ' + result.boardLockWarning.message);
398
452
  lines.push(...warningLines(result.unrecognisedStatuses));
399
453
  lines.push(summaryLine(agent, result));
400
454
  return lines.join('\n') + '\n';
@@ -467,6 +521,7 @@ export function buildHookEnvelope(agent, result, hasInitiatives) {
467
521
  });
468
522
  const problemLines = result.problems.map((p) => '- ' + oneLine(p.message, p.kind === 'lock-held' ? 360 : 160));
469
523
  const footer = [];
524
+ if (result.boardLockWarning) footer.push('Warning: ' + oneLine(result.boardLockWarning.message, 360));
470
525
  if (result.unrecognisedStatuses.length) {
471
526
  footer.push('Unrecognised status words: ' + result.unrecognisedStatuses.length + ' (warnings; `pa board check` lists them).');
472
527
  }
@@ -522,7 +577,7 @@ function parseFlags(args) {
522
577
  return flags;
523
578
  }
524
579
 
525
- async function gather(repoRoot, agent) {
580
+ async function gather(repoRoot, agent, boardFile) {
526
581
  const spawnLogFile = join(repoRoot, 'agents', agent, 'spawn-log.json');
527
582
  let log;
528
583
  try {
@@ -535,7 +590,7 @@ async function gather(repoRoot, agent) {
535
590
  }
536
591
  let parsed;
537
592
  try {
538
- parsed = await readBoard(boardPath(repoRoot, agent), { strictStatus: false });
593
+ parsed = await readBoard(boardFile, { strictStatus: false });
539
594
  } catch (e) {
540
595
  if (e instanceof SyntaxError) throw new PreconditionError('board at ' + boardRelPath(agent) + ' is not valid JSON: ' + e.message);
541
596
  throw new PreconditionError('could not read the board at ' + boardRelPath(agent) + ': ' + (e.message || e));
@@ -547,6 +602,9 @@ async function gather(repoRoot, agent) {
547
602
  // Read-only: the lock file is read, never written, broken or swept.
548
603
  let lock = null;
549
604
  try { lock = await readSpawnLogLock(spawnLogFile); } catch { lock = null; }
605
+ // The board lock beside the board, read-only the same way.
606
+ let boardLock = null;
607
+ try { boardLock = await inspectBoardLock(boardFile); } catch { boardLock = null; }
550
608
  return {
551
609
  board: parsed ? parsed.board : null,
552
610
  dispatches: log.dispatches,
@@ -554,6 +612,8 @@ async function gather(repoRoot, agent) {
554
612
  hasInitiatives: initiatives.hasRecords,
555
613
  lock,
556
614
  lockRelPath: spawnLogRelPath(agent) + '.lock',
615
+ boardLock,
616
+ boardRelPath: boardRelPath(agent),
557
617
  };
558
618
  }
559
619
 
@@ -585,10 +645,18 @@ export async function run(argv, opts = {}) {
585
645
  throw new PreconditionError(e.message || String(e));
586
646
  }
587
647
  }
588
- const agent = flags.agent ?? resolveAgentName(repoRoot);
648
+ // The one agent-board resolver every board reader and writer uses: a bad
649
+ // name or a board path that leads out of agents/<agent>/workspace/ is a
650
+ // usage error before anything is read.
651
+ const resolved = resolveAgentBoard(repoRoot, flags.agent);
652
+ if (!resolved.ok) {
653
+ throw new UsageError(resolved.message + (flags.agent === undefined ? ' (the agent from preferences)' : '')
654
+ + ', got "' + resolved.agent + '"; nothing was read');
655
+ }
656
+ const agent = resolved.agent;
589
657
  let input;
590
658
  try {
591
- input = await gather(repoRoot, agent);
659
+ input = await gather(repoRoot, agent, resolved.file);
592
660
  } catch (e) {
593
661
  if (e instanceof PreconditionError && flags.json) {
594
662
  out.write(JSON.stringify({ ok: false, exitCode: 3, agent, error: e.message }, null, 2) + '\n');
@@ -612,6 +680,8 @@ export async function run(argv, opts = {}) {
612
680
  problems: result.problems,
613
681
  unrecognisedStatuses: result.unrecognisedStatuses,
614
682
  };
683
+ // Present only when the warning fires, so a clean report keeps its shape.
684
+ if (result.boardLockWarning) payload.boardLockWarning = result.boardLockWarning;
615
685
  out.write(JSON.stringify(payload, null, 2) + '\n');
616
686
  return result.exitCode;
617
687
  }
@@ -38,6 +38,9 @@ Usage:
38
38
  pa status set [--file <p>] Replace this PA's daily status board from a JSON file or stdin
39
39
  pa status patch [flags] Merge one board item or on-me entry, set statuses, append to done
40
40
  pa status show [--json] Print the daily status board (never hand-edit its JSON)
41
+ pa status lane --key <k> Add or change a board lane (title, paused, its running item and queue)
42
+ pa status notice [flags] Add, change or dismiss a For-me notice (Risk, Money or Security)
43
+ pa status done [flags] Move a lane item to done, add a done entry, or prune old ones
41
44
  pa board check [--json] [--hook-envelope] [--agent <n>]
42
45
  Read-only: is the board current, and is every open dispatch on it?
43
46
  Exit 0 clean | 1 usage error | 2 problems found | 3 could not run
@@ -121,8 +124,8 @@ export async function main(argv) {
121
124
  return await doSession(rest);
122
125
  case 'status':
123
126
  // Bare `pa status` (and any argv that is not a board subverb) stays
124
- // the environment probe; set | patch | show write and read the daily
125
- // status board.
127
+ // the environment probe; set | patch | show | lane | notice | done
128
+ // write and read the daily status board.
126
129
  if (STATUS_BOARD_SUBVERBS.includes(rest[0])) return await runStatusBoard(rest);
127
130
  return await doStatus();
128
131
  case 'board':
@@ -9,7 +9,8 @@
9
9
  // stage appends a checkpoint { at, line, stage } to the record, sets its
10
10
  // stage, and refreshes the board item and the board's updatedAt;
11
11
  // close sets completedAt and the closing outcome (a known closed word,
12
- // done by default) and moves the item from its group to done;
12
+ // done by default) and moves the item to done, out of its group or
13
+ // its lane (running or queued), once;
13
14
  // list prints the open dispatches by the shared rule and writes nothing;
14
15
  // unlock prints the spawn-log lock's holder and removes the lock when its
15
16
  // holder is provably dead (this host, pid gone), or with --force
@@ -23,7 +24,10 @@
23
24
  //
24
25
  // The spawn-log write and then the board patch run inside one hold of the
25
26
  // spawn-log's lock (core spawn-log/write.mjs), so concurrent calls get
26
- // distinct ids and lose no record and no board item. A failed board patch never undoes the
27
+ // distinct ids and lose no record and no board item. The board patch also
28
+ // runs inside the board lock (status-board.mjs withBoardLock), the one
29
+ // `pa status` and the workspace dismiss route take, so no other board
30
+ // writer loses a change to it either. A failed board patch never undoes the
27
31
  // spawn-log write: the record stays, stdout still prints what was written,
28
32
  // stderr names the board error, the exit code is 2, and `pa board check`
29
33
  // then names the dispatch as not on the board. A missing board is such a
@@ -64,13 +68,14 @@ import { dispatchLabel } from '../../../engineering-pa-core/src/dispatch/label.m
64
68
  import { getRepoRoot } from './repo-root.mjs';
65
69
  import {
66
70
  BoardError,
71
+ BoardLockError,
67
72
  STATUS_VALUES,
68
- boardPath,
69
73
  boardRelPath,
70
74
  isValidAgentName,
71
75
  readBoard,
72
- resolveAgentName,
76
+ resolveAgentBoard,
73
77
  validateBoard,
78
+ withBoardLock,
74
79
  writeBoardAtomic,
75
80
  } from './status-board.mjs';
76
81
 
@@ -155,9 +160,12 @@ class ClosedDispatchError extends Error {
155
160
  * A failed board patch. `state` is the board's: 'missing', 'unparsed' (not
156
161
  * valid JSON), 'unreadable', 'structural' (off the schema's structure),
157
162
  * 'status' (only a status value outside STATUS_VALUES), 'write' (the write
158
- * itself failed) or 'lost' (the spawn-log lock was lost before the board's
159
- * rename, so the fence skipped the write). `onBoard` says whether a group item holds the id, when
160
- * the board parsed.
163
+ * itself failed), 'lost' (the spawn-log lock was lost before the board's
164
+ * rename, so the fence skipped the write), 'locked' (another board writer
165
+ * held the board lock for the whole wait, so the board was not read or
166
+ * written) or 'refused' (the agent-board resolver's fence, checked again
167
+ * inside the lock, refused the path, so the board was not read or written). `onBoard` says whether a board item (a group item, or a lane's
168
+ * running or queued item) holds the id, when the board parsed.
161
169
  */
162
170
  class BoardPatchError extends OpError {
163
171
  constructor(msg, state, onBoard = false) {
@@ -203,12 +211,12 @@ export async function run(argv, opts = {}) {
203
211
  if (flags[k] === undefined || String(flags[k]).trim() === '') throw new UsageError('--' + k + ' is required');
204
212
  }
205
213
  const repoRoot = opts.repoRoot ?? await getRepoRoot();
206
- const agent = pickAgent(flags.agent, repoRoot);
214
+ const { agent, file: board } = pickBoard(flags.agent, repoRoot);
207
215
  const ctx = {
208
216
  repoRoot,
209
217
  agent,
210
218
  spawnLog: join(repoRoot, 'agents', agent, 'spawn-log.json'),
211
- board: boardPath(repoRoot, agent),
219
+ board,
212
220
  flags,
213
221
  out,
214
222
  err,
@@ -257,10 +265,21 @@ function parseFlags(args, allowed) {
257
265
  return flags;
258
266
  }
259
267
 
260
- function pickAgent(flag, repoRoot) {
261
- if (flag === undefined) return resolveAgentName(repoRoot);
262
- if (!isValidAgentName(flag)) throw new UsageError('--agent must be a lowercase agent folder name, got "' + flag + '"');
263
- return flag;
268
+ /**
269
+ * The agent and its board file, through the one resolver every board reader
270
+ * and writer uses (resolveAgentBoard in status-board.mjs): --agent when
271
+ * given, else the configured agent. A bad name or a board path that leads
272
+ * out of agents/<agent>/workspace/ is a usage error (exit 1) before the
273
+ * spawn-log or the board is read or written.
274
+ */
275
+ function pickBoard(flag, repoRoot) {
276
+ if (flag !== undefined && !isValidAgentName(flag)) throw new UsageError('--agent must be a lowercase agent folder name, got "' + flag + '"');
277
+ const r = resolveAgentBoard(repoRoot, flag);
278
+ if (!r.ok) {
279
+ const from = flag === undefined ? ' (the agent from preferences)' : '';
280
+ throw new UsageError(r.message + from + ', got "' + r.agent + '"; nothing was read or written');
281
+ }
282
+ return r;
264
283
  }
265
284
 
266
285
  function stamp(now) {
@@ -293,10 +312,25 @@ export function stageText(stage) {
293
312
  /**
294
313
  * Load the board for a patch, apply `change(board)`, stamp updatedAt and
295
314
  * write it atomically, with the spawn-log lock's `fence` run just before the
296
- * board's rename. Throws OpError when the board is missing, not JSON, off
297
- * the schema, the write is refused, or the lock was lost.
315
+ * board's rename, all inside the board lock. Throws OpError when the board
316
+ * is missing, not JSON, off the schema, the write is refused, the
317
+ * spawn-log lock was lost, or the board lock stayed held.
298
318
  */
299
319
  async function patchBoard(ctx, id, change, fence) {
320
+ try {
321
+ return await withBoardLock(ctx.board, () => {
322
+ // The resolver's fence again, now the lock is held.
323
+ const again = resolveAgentBoard(ctx.repoRoot, ctx.agent);
324
+ if (!again.ok) throw new BoardPatchError(again.message + '; nothing was written', 'refused');
325
+ return patchBoardLocked(ctx, id, change, fence);
326
+ });
327
+ } catch (e) {
328
+ if (e instanceof BoardLockError) throw new BoardPatchError(e.message, 'locked');
329
+ throw e;
330
+ }
331
+ }
332
+
333
+ async function patchBoardLocked(ctx, id, change, fence) {
300
334
  let parsed;
301
335
  try {
302
336
  parsed = await readBoard(ctx.board, { strictStatus: true });
@@ -332,7 +366,7 @@ async function patchBoard(ctx, id, change, fence) {
332
366
  }
333
367
 
334
368
  function holdsId(board, id) {
335
- return Boolean(board && Array.isArray(board.groups)) && itemsWithId({ groups: board.groups.filter((g) => g && typeof g === 'object') }, id).length > 0;
369
+ return Boolean(board) && typeof board === 'object' && itemsWithId(board, id).length > 0;
336
370
  }
337
371
 
338
372
  /**
@@ -345,6 +379,8 @@ function boardPatchFailure(e, { id, agent, open }) {
345
379
  let check;
346
380
  if (e.state === 'unparsed' || e.state === 'unreadable' || e.state === 'structural') {
347
381
  check = '`pa board check` exits 3 naming ' + file + ' until the board is fixed';
382
+ } else if (e.state === 'locked' || e.state === 'refused') {
383
+ check = 'the board at ' + file + ' was left as it was, so `pa board check` reads it unchanged';
348
384
  } else if (!open) {
349
385
  check = '`pa board check` does not name a closed dispatch';
350
386
  } else if (e.onBoard) {
@@ -384,11 +420,23 @@ function refuseClosed(merged, id, verb, ctx) {
384
420
  throw new ClosedDispatchError(id + ' is closed (' + why.join(', ') + '); pa dispatch ' + verb + ' refuses a closed dispatch and wrote nothing');
385
421
  }
386
422
 
423
+ /**
424
+ * Every board item holding the id: group items first ({ gi, ii, item }),
425
+ * then each lane's running item and queued items ({ lane, slot, item }).
426
+ * Safe on a board off the schema (anything not an object is passed over).
427
+ */
387
428
  function itemsWithId(board, id) {
388
429
  const hits = [];
389
- board.groups.forEach((g, gi) => (Array.isArray(g.items) ? g.items : []).forEach((it, ii) => {
390
- if (Array.isArray(it.ids) && it.ids.includes(id)) hits.push({ gi, ii, item: it });
430
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
431
+ const holds = (it) => isObj(it) && Array.isArray(it.ids) && it.ids.includes(id);
432
+ (Array.isArray(board.groups) ? board.groups : []).forEach((g, gi) => (isObj(g) && Array.isArray(g.items) ? g.items : []).forEach((it, ii) => {
433
+ if (holds(it)) hits.push({ gi, ii, item: it });
391
434
  }));
435
+ for (const lane of Array.isArray(board.lanes) ? board.lanes : []) {
436
+ if (!isObj(lane)) continue;
437
+ if (holds(lane.running)) hits.push({ lane, slot: 'running', item: lane.running });
438
+ for (const it of Array.isArray(lane.queued) ? lane.queued : []) if (holds(it)) hits.push({ lane, slot: 'queued', item: it });
439
+ }
392
440
  return hits;
393
441
  }
394
442
 
@@ -521,12 +569,19 @@ async function doClose(ctx) {
521
569
  return result.after ? 0 : 2;
522
570
  }
523
571
 
524
- /** Take the dispatch's items out of their groups and add one done entry. */
572
+ /**
573
+ * Take the dispatch's items out of their groups and their lanes (running or
574
+ * queued) and add one done entry.
575
+ */
525
576
  function moveToDone(board, id, record, f, outcome, now) {
526
577
  const hits = itemsWithId(board, id);
527
578
  const base = hits.length ? hits[0].item : null;
528
- const group = hits.length ? board.groups[hits[0].gi].label : null;
529
- for (const { gi, ii } of [...hits].reverse()) board.groups[gi].items.splice(ii, 1);
579
+ const group = hits.length && hits[0].lane === undefined ? board.groups[hits[0].gi].label : null;
580
+ for (const h of [...hits].reverse()) {
581
+ if (h.lane === undefined) board.groups[h.gi].items.splice(h.ii, 1);
582
+ else if (h.slot === 'running') delete h.lane.running;
583
+ else h.lane.queued.splice(h.lane.queued.indexOf(h.item), 1);
584
+ }
530
585
  if (board.done.some((d) => Array.isArray(d.ids) && d.ids.includes(id))) return;
531
586
  const entry = { title: base ? base.title : (str(record.label) || id), status: BOARD_DONE };
532
587
  if (str(group)) entry.workstream = group;