@stravica-ai/digital-operator-pa 0.1.70 → 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,7 +6,8 @@
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
@@ -33,6 +34,14 @@
33
34
  // `unrecognised status` warnings: the human output gives one line per word
34
35
  // with its count and at most five ids, `--json` lists every one. They never
35
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.
36
45
  //
37
46
  // Exit codes: 0 clean | 1 usage error | 2 one or more problems |
38
47
  // 3 the check could not run (no repo root, the spawn-log or the board
@@ -62,7 +71,12 @@ import {
62
71
 
63
72
  import { getRepoRoot } from './repo-root.mjs';
64
73
  import {
74
+ BOARD_LOCK_LONG_HELD_MS,
75
+ BOARD_LOCK_WAIT_MS,
65
76
  boardRelPath,
77
+ formatLockAge,
78
+ inspectBoardLock,
79
+ isLongHeldBoardLock,
66
80
  isValidAgentName,
67
81
  readBoard,
68
82
  resolveAgentBoard,
@@ -89,6 +103,9 @@ Reports, one line each:
89
103
  dispatch with no stage, and an open question with no on-me entry
90
104
  Unrecognised status or outcome words are warnings (exit 0): one line per word,
91
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.
92
109
 
93
110
  Flags:
94
111
  --json One JSON object on stdout
@@ -96,7 +113,8 @@ Flags:
96
113
  --agent <name> The agent folder (default: preferences paName, else the
97
114
  single agents/* folder holding next-session.md)
98
115
 
99
- 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.
100
118
 
101
119
  Exit codes:
102
120
  0 clean | 1 usage error | 2 problems found | 3 the check could not run
@@ -198,6 +216,9 @@ function boardIdSet(board, key) {
198
216
  * @param {object|null} [input.lock] the spawn-log lock as read (readSpawnLogLock),
199
217
  * or null/absent when there is none or it was not read
200
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
201
222
  * @param {Date|number|Function} [input.now]
202
223
  */
203
224
  export function checkBoard(input = {}) {
@@ -310,6 +331,31 @@ export function checkBoard(input = {}) {
310
331
  openNotOnBoard,
311
332
  problems,
312
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 + '`',
313
359
  };
314
360
  }
315
361
 
@@ -358,7 +404,7 @@ function summaryLine(agent, result) {
358
404
  const live = result.open.live.length;
359
405
  const stale = result.open.stale.length;
360
406
  const nProblems = result.problems.length;
361
- const nWarnings = result.unrecognisedStatuses.length;
407
+ const nWarnings = result.unrecognisedStatuses.length + (result.boardLockWarning ? 1 : 0);
362
408
  const head = nProblems === 0
363
409
  ? 'board check: clean for ' + agent
364
410
  : 'board check: ' + plural(nProblems, 'problem', 'problems') + ' for ' + agent;
@@ -402,6 +448,7 @@ export function warningLines(unrecognisedStatuses) {
402
448
  export function renderHuman(agent, result) {
403
449
  const lines = [];
404
450
  for (const p of result.problems) lines.push('problem: ' + p.message);
451
+ if (result.boardLockWarning) lines.push('warning: ' + result.boardLockWarning.message);
405
452
  lines.push(...warningLines(result.unrecognisedStatuses));
406
453
  lines.push(summaryLine(agent, result));
407
454
  return lines.join('\n') + '\n';
@@ -474,6 +521,7 @@ export function buildHookEnvelope(agent, result, hasInitiatives) {
474
521
  });
475
522
  const problemLines = result.problems.map((p) => '- ' + oneLine(p.message, p.kind === 'lock-held' ? 360 : 160));
476
523
  const footer = [];
524
+ if (result.boardLockWarning) footer.push('Warning: ' + oneLine(result.boardLockWarning.message, 360));
477
525
  if (result.unrecognisedStatuses.length) {
478
526
  footer.push('Unrecognised status words: ' + result.unrecognisedStatuses.length + ' (warnings; `pa board check` lists them).');
479
527
  }
@@ -554,6 +602,9 @@ async function gather(repoRoot, agent, boardFile) {
554
602
  // Read-only: the lock file is read, never written, broken or swept.
555
603
  let lock = null;
556
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; }
557
608
  return {
558
609
  board: parsed ? parsed.board : null,
559
610
  dispatches: log.dispatches,
@@ -561,6 +612,8 @@ async function gather(repoRoot, agent, boardFile) {
561
612
  hasInitiatives: initiatives.hasRecords,
562
613
  lock,
563
614
  lockRelPath: spawnLogRelPath(agent) + '.lock',
615
+ boardLock,
616
+ boardRelPath: boardRelPath(agent),
564
617
  };
565
618
  }
566
619
 
@@ -627,6 +680,8 @@ export async function run(argv, opts = {}) {
627
680
  problems: result.problems,
628
681
  unrecognisedStatuses: result.unrecognisedStatuses,
629
682
  };
683
+ // Present only when the warning fires, so a clean report keeps its shape.
684
+ if (result.boardLockWarning) payload.boardLockWarning = result.boardLockWarning;
630
685
  out.write(JSON.stringify(payload, null, 2) + '\n');
631
686
  return result.exitCode;
632
687
  }
@@ -90,6 +90,9 @@ Notes:
90
90
  - patch --id matches items (or with --on-me, on-me entries) whose ids hold
91
91
  the id; --match matches the exact title (or question). More than one
92
92
  match is refused. No match creates the entry (an item needs --group).
93
+ - On a lane item or a group item, --ids with --id X keeps X in the ids:
94
+ the list as given when it holds X, else X first and then the list, so
95
+ a later --id X still finds the item.
93
96
  - --status done also appends the item to the done list.
94
97
  - Lanes: a lane key is a lowercase slug. The "${MAIN_LANE_KEY}" lane is always shown,
95
98
  first; any other lane a writer adds is shown after it, in board order.
@@ -442,7 +445,7 @@ function patchItem(board, f, now) {
442
445
  if (f.group === undefined) throw new UsageError('no item matches; to add one, give --group');
443
446
  const title = f.title ?? f.match;
444
447
  if (title === undefined) throw new UsageError('no item matches --id ' + f.id + '; to add one, give --title and --group');
445
- const ids = mergeIds(splitList(f.ids, ',') || [], f.id);
448
+ const ids = keepIdFirst(splitList(f.ids, ',') || [], f.id);
446
449
  const item = {
447
450
  title,
448
451
  status: normaliseStatusValue(f.status ?? 'queued'),
@@ -469,7 +472,7 @@ function patchItem(board, f, now) {
469
472
  for (const k of ITEM_FIELDS) {
470
473
  if (f[k] !== undefined) item[k] = k === 'status' ? normaliseStatusValue(f[k]) : f[k];
471
474
  }
472
- if (f.ids !== undefined) item.ids = splitList(f.ids, ',');
475
+ if (f.ids !== undefined) item.ids = keepIdFirst(splitList(f.ids, ','), f.id);
473
476
  item.updatedAt = now;
474
477
  let label = fromGroup.label;
475
478
  if (f.group !== undefined && f.group !== fromGroup.label) {
@@ -523,6 +526,18 @@ function mergeIds(ids, id) {
523
526
  return ids;
524
527
  }
525
528
 
529
+ /**
530
+ * The ids of a lane item or a group item written with --ids: the list as
531
+ * given when it holds the --id, or the --id first and then the list when it
532
+ * does not, so a later --id still finds the item. With no --id (a --match
533
+ * write), the list as given. On-me and done entries keep mergeIds.
534
+ */
535
+ export function keepIdFirst(ids, id) {
536
+ const list = Array.isArray(ids) ? ids : [];
537
+ if (id === undefined || list.includes(id)) return list;
538
+ return [id, ...list];
539
+ }
540
+
526
541
  function groupFor(board, label) {
527
542
  let g = board.groups.find((x) => x.label === label);
528
543
  if (!g) {
@@ -695,7 +710,7 @@ async function patchLaneLocked(ctx, key) {
695
710
  if (!hit) {
696
711
  const title = f.title ?? f.match;
697
712
  if (title === undefined) throw new UsageError('no item in lane ' + key + ' matches --id ' + f.id + '; to add one, give --title');
698
- const item = { title, ids: mergeIds(splitList(f.ids, ',') || [], f.id) };
713
+ const item = { title, ids: keepIdFirst(splitList(f.ids, ',') || [], f.id) };
699
714
  hit = { lane, slot: null, item };
700
715
  note = 'item "' + title + '" added to lane ' + key;
701
716
  } else if (hit.lane !== lane) {
@@ -709,7 +724,7 @@ async function patchLaneLocked(ctx, key) {
709
724
  const item = hit.item;
710
725
  for (const k of ['title', 'where', 'stage', 'note', 'link', 'after', 'project']) if (f[k] !== undefined) item[k] = f[k];
711
726
  if (f.status !== undefined) item.status = normaliseStatusValue(f.status);
712
- if (f.ids !== undefined && hits.length) item.ids = splitList(f.ids, ',');
727
+ if (f.ids !== undefined && hits.length) item.ids = keepIdFirst(splitList(f.ids, ','), f.id);
713
728
  if (f.started !== undefined) item.startedAt = f.started;
714
729
  if (f.estimate !== undefined) item.estimatedFinishAt = f.estimate;
715
730
  item.updatedAt = now;
@@ -560,6 +560,12 @@ export async function writeBoardAtomic(file, board, opts = {}) {
560
560
  // lock can avoid that. The rule is that the next writer finds the dead
561
561
  // pid, recovers the lock within the bounded wait, and nothing is lost:
562
562
  // the board is only ever replaced by a whole temp file's rename.
563
+ // - A same-host lock whose pid is alive is never broken, however old, so
564
+ // a pid reused after a kill keeps a dead writer's lock live. It is made
565
+ // visible instead: BoardLockError names the holder's pid and the lock's
566
+ // age, and `pa board check` warns once a live same-host holder's lock is
567
+ // older than BOARD_LOCK_LONG_HELD_MS (inspectBoardLock, read-only),
568
+ // naming the manual clear. Nothing removes it automatically.
563
569
  // - A folder the lock call had to create (no board yet) is removed again
564
570
  // when it is still empty, so a refused write leaves no trace.
565
571
 
@@ -568,14 +574,20 @@ export const BOARD_LOCK_BREAK_SUFFIX = '.break';
568
574
  export const BOARD_LOCK_WAIT_MS = 3000;
569
575
  export const BOARD_LOCK_RETRY_MS = 10;
570
576
  export const BOARD_LOCK_STALE_MS = 10000;
577
+ /** Age past which `pa board check` warns on a lock held by a live pid on this host. */
578
+ export const BOARD_LOCK_LONG_HELD_MS = 10 * 60 * 1000;
571
579
 
572
580
  const BOARD_LOCK_HOST = createHash('sha256').update(osHostname()).digest('hex').slice(0, 8);
573
581
 
574
582
  /** The lock stayed held for the whole wait: nothing was read or written. */
575
583
  export class BoardLockError extends Error {
576
- constructor(path, holder, waitMs) {
584
+ constructor(path, holder, waitMs, nowMs = Date.now()) {
577
585
  const pid = holder && holder.owner && Number.isInteger(holder.owner.pid) ? holder.owner.pid : null;
578
- super('the status board is locked by another writer' + (pid !== null ? ' (pid ' + pid + ')' : '')
586
+ const ageMs = holder && Number.isFinite(holder.mtimeMs) ? nowMs - holder.mtimeMs : null;
587
+ const about = [];
588
+ if (pid !== null) about.push('pid ' + pid);
589
+ if (ageMs !== null) about.push('lock ' + formatLockAge(ageMs) + ' old');
590
+ super('the status board is locked by another writer' + (about.length ? ' (' + about.join(', ') + ')' : '')
579
591
  + ' and stayed locked for ' + (waitMs / 1000) + ' s; nothing was written, try again');
580
592
  this.name = 'BoardLockError';
581
593
  this.path = path;
@@ -583,6 +595,20 @@ export class BoardLockError extends Error {
583
595
  }
584
596
  }
585
597
 
598
+ /**
599
+ * A lock's age for a message: whole seconds under a minute, whole minutes
600
+ * under an hour, then hours and minutes. A negative age (a file time ahead
601
+ * of the clock) reads as 0 s.
602
+ */
603
+ export function formatLockAge(ms) {
604
+ const secs = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1000));
605
+ if (secs < 60) return secs + ' s';
606
+ const mins = Math.floor(secs / 60);
607
+ if (mins < 60) return mins + ' min';
608
+ const rest = mins % 60;
609
+ return Math.floor(mins / 60) + ' h' + (rest ? ' ' + rest + ' min' : '');
610
+ }
611
+
586
612
  /** The lock file of a board file. */
587
613
  export function boardLockPath(file) {
588
614
  return file + BOARD_LOCK_SUFFIX;
@@ -691,6 +717,38 @@ export function isStaleBoardLock(holder, staleMs = BOARD_LOCK_STALE_MS, nowMs =
691
717
  return nowMs - holder.mtimeMs > staleMs;
692
718
  }
693
719
 
720
+ /**
721
+ * A board's lock as found, read-only, for `pa board check`: null when there
722
+ * is none; else its holder's pid (null with no readable owner), whether the
723
+ * holder ran on this host, whether that pid is alive (the stale rule's own
724
+ * check; null for a foreign or ownerless lock) and the lock file's time. It
725
+ * never writes, breaks or removes the lock.
726
+ *
727
+ * @param {string} file the board file
728
+ */
729
+ export async function inspectBoardLock(file) {
730
+ const lock = await readBoardLock(boardLockPath(file));
731
+ if (!lock) return null;
732
+ const o = lock.owner;
733
+ const sameHost = Boolean(o && o.host === BOARD_LOCK_HOST);
734
+ return {
735
+ pid: o ? o.pid : null,
736
+ sameHost,
737
+ alive: sameHost ? processAlive(o.pid) : null,
738
+ mtimeMs: lock.mtimeMs,
739
+ };
740
+ }
741
+
742
+ /**
743
+ * The long-held rule on an inspected lock: a live holder on this host whose
744
+ * lock file is more than thresholdMs old at nowMs. Pure.
745
+ */
746
+ export function isLongHeldBoardLock(lock, nowMs = Date.now(), thresholdMs = BOARD_LOCK_LONG_HELD_MS) {
747
+ if (!lock || lock.sameHost !== true || lock.alive !== true) return false;
748
+ if (!Number.isInteger(lock.pid) || !Number.isFinite(lock.mtimeMs)) return false;
749
+ return nowMs - lock.mtimeMs > thresholdMs;
750
+ }
751
+
694
752
  /** Alive unless process.kill(pid, 0) says ESRCH; EPERM and any other answer read as alive. */
695
753
  function processAlive(pid) {
696
754
  try {
@@ -31,7 +31,7 @@ The PA updates the board, through the CLI, at every one of these moments, in the
31
31
 
32
32
  ## The write path
33
33
 
34
- The PA never hand-edits the JSON. The CLI reads the clock, validates against the schema and writes atomically (temp file, then rename), so a bad write never lands and the page never reads a half-written file. Every writer (the `pa status` verbs, `pa dispatch` and the page's Dismiss) reads, changes and writes the board inside one lock, `status-board.json.lock` beside it, so two writers never lose each other's change; a write that cannot get the lock within a few seconds writes nothing and says so. A lock left by a writer on the same host counts as stale only when that writer's process is gone; a live writer is never broken, however long it holds. A lock from another host, or one with no readable owner, counts as stale after ten seconds. A stale lock is removed only under a short break mutex, `status-board.json.lock.break`, and only while it is still the lock judged stale, so recovering it never lets two writers in. A crash or a kill can leave the lock file behind: the next writer recovers it within its wait, and nothing is lost, because the board itself is only ever replaced whole. Neither file is ever committed. An option's value never starts with `--`, in the separate form or the `--flag=value` form, and `--started`, `--estimate` and `--ended` take an ISO 8601 date-time with a zone on a real calendar date, such as `2030-01-15T09:30:00Z` (`2030-02-31T09:30:00Z` is refused).
34
+ The PA never hand-edits the JSON. The CLI reads the clock, validates against the schema and writes atomically (temp file, then rename), so a bad write never lands and the page never reads a half-written file. Every writer (the `pa status` verbs, `pa dispatch` and the page's Dismiss) reads, changes and writes the board inside one lock, `status-board.json.lock` beside it, so two writers never lose each other's change; a write that cannot get the lock within a few seconds writes nothing and says so. A lock left by a writer on the same host counts as stale only when that writer's process is gone; a live writer is never broken, however long it holds. A long hold is made visible instead: a writer refused by the lock names the holder's pid and the lock's age, and `pa board check` warns once a lock held by a live writer on this host is more than ten minutes old, naming the manual clear, `rm` of the lock file after checking that pid is not a board writer; nothing clears it automatically. A lock from another host, or one with no readable owner, counts as stale after ten seconds. A stale lock is removed only under a short break mutex, `status-board.json.lock.break`, and only while it is still the lock judged stale, so recovering it never lets two writers in. A crash or a kill can leave the lock file behind: the next writer recovers it within its wait, and nothing is lost, because the board itself is only ever replaced whole. Neither file is ever committed. An option's value never starts with `--`, in the separate form or the `--flag=value` form, and `--started`, `--estimate` and `--ended` take an ISO 8601 date-time with a zone on a real calendar date, such as `2030-01-15T09:30:00Z` (`2030-02-31T09:30:00Z` is refused).
35
35
 
36
36
  | Verb | Use |
37
37
  |---|---|
@@ -321,16 +321,17 @@
321
321
  document.addEventListener('visibilitychange', onVisibility);
322
322
  }
323
323
 
324
- // Fire after load rather than DOMContentLoaded. On busy pages (e.g.
325
- // home.html fires ~9 API fetches) the HTTP/1.1 6-connection pool means
326
- // DOMContentLoaded-initiated fetches queue behind the inline script's
327
- // fetches. Waiting for load is cheap (sub-100ms on quiet pages, a
328
- // second on busy ones) and gives the banner a clear slot. The banner
329
- // is a background-info surface, not a critical-path element, so modest
330
- // delay is the right trade.
331
- if (document.readyState === 'complete') {
332
- boot();
324
+ // Start on DOMContentLoaded, or at once when the document is already
325
+ // past it. The top window's `load` event waits for every subresource,
326
+ // the shell's iframe document included, so a slow iframe page held the
327
+ // banner back for as long as that page took (w-2026-10-31-dave-001).
328
+ // The banner needs only the shell's own DOM: its slot is in the shell's
329
+ // markup, or boot() makes one at the top of the body. The script loads
330
+ // with `defer`, so by the time it runs the DOM is parsed and the start
331
+ // is usually immediate.
332
+ if (document.readyState === 'loading') {
333
+ document.addEventListener('DOMContentLoaded', boot);
333
334
  } else {
334
- window.addEventListener('load', boot);
335
+ boot();
335
336
  }
336
337
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
4
4
  "description": "Digital Operator PA: single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",