@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.
- package/dist/engineering-pa-cli/bin/pa +18 -1
- package/dist/engineering-pa-cli/src/pa/board-check.mjs +58 -3
- package/dist/engineering-pa-cli/src/pa/status-board-cli.mjs +19 -4
- package/dist/engineering-pa-cli/src/pa/status-board.mjs +60 -2
- package/dist/engineering-pa-core/knowledge/work-management/status-board.md +1 -1
- package/dist/engineering-pa-workspace/app/banner.js +11 -10
- package/package.json +1 -1
|
@@ -1,2 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
+
boot();
|
|
335
336
|
}
|
|
336
337
|
})();
|
package/package.json
CHANGED