session-orchestrator 3.23.0 → 3.24.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -58,6 +58,8 @@ import { readConfigFile, parseSessionConfig } from '../config.mjs';
|
|
|
58
58
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
59
59
|
import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
|
|
60
60
|
import { atomicWriteWithBackup } from '../io.mjs';
|
|
61
|
+
import { withBoardLock } from './board-lock.mjs';
|
|
62
|
+
import { expandTilde } from '../common.mjs';
|
|
61
63
|
|
|
62
64
|
/** Frontmatter sentinel that identifies generator-owned board files. */
|
|
63
65
|
export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
|
|
@@ -180,22 +182,6 @@ const nameSlot = (repo) => `n:${foldKey(repo)}`;
|
|
|
180
182
|
|
|
181
183
|
// ── Path helpers ────────────────────────────────────────────────────────────────
|
|
182
184
|
|
|
183
|
-
/**
|
|
184
|
-
* Expand a leading `~` to the current user's home directory. Inlined here on
|
|
185
|
-
* purpose — the shared helper is private elsewhere, and a shared
|
|
186
|
-
* `vault-write-guard.mjs` extraction is deferred to a later epic (W2 forbids a
|
|
187
|
-
* new shared file in this slice).
|
|
188
|
-
*
|
|
189
|
-
* @param {string} p
|
|
190
|
-
* @returns {string}
|
|
191
|
-
*/
|
|
192
|
-
function expandHome(p) {
|
|
193
|
-
if (typeof p !== 'string' || p.length === 0) return p;
|
|
194
|
-
if (p === '~') return os.homedir();
|
|
195
|
-
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
196
|
-
return p;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
185
|
/**
|
|
200
186
|
* Resolve the board file path from a vault directory.
|
|
201
187
|
*
|
|
@@ -203,7 +189,7 @@ function expandHome(p) {
|
|
|
203
189
|
* @returns {string} `<vaultDir>/01-projects/_active-sessions.md`
|
|
204
190
|
*/
|
|
205
191
|
export function resolveBoardPath(vaultDir) {
|
|
206
|
-
return path.join(
|
|
192
|
+
return path.join(expandTilde(vaultDir), '01-projects', '_active-sessions.md');
|
|
207
193
|
}
|
|
208
194
|
|
|
209
195
|
// ── Formatting helpers ───────────────────────────────────────────────────────────
|
|
@@ -729,6 +715,10 @@ export const BOARD_EVENT = 'orchestrator.vault.board_written';
|
|
|
729
715
|
* @param {number} [opts.reposSwept] — candidates {@link enumerateCandidates}
|
|
730
716
|
* returned, on the {@link sweepBoard} path only.
|
|
731
717
|
* @param {number} [opts.durationMs]
|
|
718
|
+
* @param {{ locked: boolean, reason?: string, stale_override?: string, waited_ms: number }} [opts.lock]
|
|
719
|
+
* — board-lock outcome, present only when the lock was actually attempted
|
|
720
|
+
* (i.e. not on the early no-op guards, not on dry-run). Never carries the lock
|
|
721
|
+
* PATH — that is a `$HOME`-rooted string, the CP1 shape this payload keeps out.
|
|
732
722
|
* @returns {Promise<void>}
|
|
733
723
|
*/
|
|
734
724
|
/**
|
|
@@ -765,7 +755,7 @@ function telemetrySafePath(outputPath) {
|
|
|
765
755
|
return base.length > 0 ? base : undefined;
|
|
766
756
|
}
|
|
767
757
|
|
|
768
|
-
async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs }) {
|
|
758
|
+
async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs, lock }) {
|
|
769
759
|
// Refuse the SO_PROJECT_DIR fallback instead of guessing a destination.
|
|
770
760
|
// Without an explicit repoRoot, `emitEvent` resolves `eventsFilePath(undefined)`
|
|
771
761
|
// and writes into whatever tree the ambient env points at — so `mirrorBoard()`
|
|
@@ -798,6 +788,12 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
|
|
|
798
788
|
...(Number.isFinite(rows) ? { rows } : {}),
|
|
799
789
|
...(Number.isFinite(reposSwept) ? { repos_swept: reposSwept } : {}),
|
|
800
790
|
...(Number.isFinite(durationMs) ? { duration_ms: durationMs } : {}),
|
|
791
|
+
// Lock diagnostics (absent on every path that never took the lock: the
|
|
792
|
+
// early no-op guards and dry-run). `lock.locked === false` marks a
|
|
793
|
+
// fail-open unlocked write; `lock.stale_override` marks an acquire that
|
|
794
|
+
// aged out someone else's lock — the observable behind board-lock's
|
|
795
|
+
// DEFAULT_STALE_MS revisit trigger.
|
|
796
|
+
...(lock && typeof lock === 'object' ? { lock } : {}),
|
|
801
797
|
// #1147: join key parity with the sibling `narrative_mirrored` event,
|
|
802
798
|
// which has carried attribution since #1073. Without it a board record
|
|
803
799
|
// cannot be joined to the session that wrote it. Both keys are OMITTED
|
|
@@ -851,7 +847,10 @@ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows
|
|
|
851
847
|
* `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
|
|
852
848
|
* and bleeds into the assertion (issue #783). Production callers omit this — the
|
|
853
849
|
* default (real owner.yaml resolution) is the correct host-local behavior there.
|
|
854
|
-
* @returns {Promise<{ result: { action: string, path?: string }, rows?: number
|
|
850
|
+
* @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
|
|
851
|
+
* lock?: { locked: boolean, reason?: string, stale_override?: string, waited_ms: number } }>}
|
|
852
|
+
* `lock` is present only on the locked path (absent on the early no-op guards
|
|
853
|
+
* and on dry-run, which deliberately takes no lock).
|
|
855
854
|
* `rows` is present only once the render was reached — see
|
|
856
855
|
* {@link emitBoardEvent}'s `rows` contract. The public {@link mirrorBoard}
|
|
857
856
|
* wrapper unwraps `result` so the caller-visible return shape is unchanged.
|
|
@@ -880,7 +879,7 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
|
|
|
880
879
|
}
|
|
881
880
|
|
|
882
881
|
// Safety: the resolved vault dir must live under $HOME.
|
|
883
|
-
const expandedVault =
|
|
882
|
+
const expandedVault = expandTilde(vaultDir);
|
|
884
883
|
const home = os.homedir();
|
|
885
884
|
const inHome = validatePathInsideProject(expandedVault, home);
|
|
886
885
|
if (!inHome.ok) {
|
|
@@ -934,132 +933,171 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
|
|
|
934
933
|
|
|
935
934
|
const outputPath = resolveBoardPath(vaultDir);
|
|
936
935
|
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
if (
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
//
|
|
976
|
-
// the
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
|
|
936
|
+
// Everything below — the two reads of the existing board, the merge, and the
|
|
937
|
+
// write — is ONE read-modify-write over a file shared by every repo on the
|
|
938
|
+
// host (issue #1180). Serialise it on the vault-scoped board lock so a
|
|
939
|
+
// concurrent sweepBoard() from another repo cannot compute its merge from a
|
|
940
|
+
// base we are about to replace. Fail-open: withBoardLock runs the closure
|
|
941
|
+
// unlocked (with a WARN) rather than let a contended lock abort the phase.
|
|
942
|
+
const mergeAndWrite = async () => {
|
|
943
|
+
// Read the EXISTING generator-owned board (if any) to:
|
|
944
|
+
// 1. preserve its `created:` — otherwise every render differs on `created:`
|
|
945
|
+
// and the noop-skip in writeBoard would never fire.
|
|
946
|
+
// 2. recover the prior per-repo status — drives the `closed` derivation for
|
|
947
|
+
// repos NOT in this update (idempotent merge: their rows are re-derived).
|
|
948
|
+
// Both maps are keyed by {@link foldKey}(repo) — case-insensitively folded
|
|
949
|
+
// (issue #719) — so two prior rows differing only by case (e.g.
|
|
950
|
+
// `some-repo` vs `Some-Repo`, the same physical directory on a
|
|
951
|
+
// case-insensitive-preserving filesystem like APFS) collapse to ONE entry
|
|
952
|
+
// instead of coexisting as duplicates. The row OBJECTS keep their original
|
|
953
|
+
// `repo` string untouched, so `renderBoard` still displays true casing.
|
|
954
|
+
const fsReadFile = fs?.readFileSync ?? readFileSync;
|
|
955
|
+
const fsExists = fs?.existsSync ?? existsSync;
|
|
956
|
+
let createdIso;
|
|
957
|
+
const priorStatusByRepo = new Map(); // LEGACY rows only — see collectRows contract
|
|
958
|
+
const priorStatusByKey = new Map(); // boardKey → status (authoritative since #871)
|
|
959
|
+
const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) → prior row
|
|
960
|
+
if (fsExists(outputPath)) {
|
|
961
|
+
let existing;
|
|
962
|
+
try {
|
|
963
|
+
existing = fsReadFile(outputPath, 'utf8');
|
|
964
|
+
} catch {
|
|
965
|
+
existing = null;
|
|
966
|
+
}
|
|
967
|
+
if (existing) {
|
|
968
|
+
const fm = parseFrontmatter(existing);
|
|
969
|
+
if (fm && fm['_generator'] === GENERATOR_MARKER) {
|
|
970
|
+
if (fm['created']) createdIso = fm['created'];
|
|
971
|
+
for (const prior of parseBoardRows(existing)) {
|
|
972
|
+
// Dual-key slotting (#871): a keyed row owns its own hash slot; a
|
|
973
|
+
// legacy (6-column) row falls back to its folded display name. Two
|
|
974
|
+
// keyed rows can only collide when they resolve to the SAME path, so
|
|
975
|
+
// the heartbeat-preference resolution below is now reached almost
|
|
976
|
+
// exclusively by legacy rows — which is precisely the case it was
|
|
977
|
+
// written for (#719).
|
|
978
|
+
const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
|
|
979
|
+
const collidingPrior = preservedRows.get(key);
|
|
980
|
+
if (collidingPrior) {
|
|
981
|
+
// Collision WITHIN parseBoardRows output — two prior rows fold to
|
|
982
|
+
// the same key with no fresh row in play yet (that upsert happens
|
|
983
|
+
// below). Prefer the row with the most-recent `heartbeat` rather
|
|
984
|
+
// than silently last-in-file-order. Guard: if either heartbeat is
|
|
985
|
+
// unparsable, fall through to last-written-wins (the pre-#719
|
|
986
|
+
// default) by NOT skipping the overwrite below.
|
|
987
|
+
const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
|
|
988
|
+
const priorTs = Date.parse(prior.heartbeat ?? '');
|
|
989
|
+
if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
|
|
990
|
+
// The already-preserved row is strictly newer — keep it, skip
|
|
991
|
+
// this older colliding row entirely.
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
987
994
|
}
|
|
995
|
+
if (prior.key) {
|
|
996
|
+
priorStatusByKey.set(prior.key, prior.status);
|
|
997
|
+
} else {
|
|
998
|
+
// LEGACY rows only. Seeding this map from keyed rows too would let
|
|
999
|
+
// repo B (never seen, same basename) inherit repo A's terminal
|
|
1000
|
+
// status through the name fallback in collectRows — reintroducing
|
|
1001
|
+
// the identity collision #871 exists to remove, one layer down.
|
|
1002
|
+
priorStatusByRepo.set(foldKey(prior.repo), prior.status);
|
|
1003
|
+
}
|
|
1004
|
+
preservedRows.set(key, prior);
|
|
988
1005
|
}
|
|
989
|
-
if (prior.key) {
|
|
990
|
-
priorStatusByKey.set(prior.key, prior.status);
|
|
991
|
-
} else {
|
|
992
|
-
// LEGACY rows only. Seeding this map from keyed rows too would let
|
|
993
|
-
// repo B (never seen, same basename) inherit repo A's terminal
|
|
994
|
-
// status through the name fallback in collectRows — reintroducing
|
|
995
|
-
// the identity collision #871 exists to remove, one layer down.
|
|
996
|
-
priorStatusByRepo.set(foldKey(prior.repo), prior.status);
|
|
997
|
-
}
|
|
998
|
-
preservedRows.set(key, prior);
|
|
999
1006
|
}
|
|
1000
1007
|
}
|
|
1001
1008
|
}
|
|
1002
|
-
}
|
|
1003
1009
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1010
|
+
const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
|
|
1011
|
+
|
|
1012
|
+
// TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
|
|
1013
|
+
// Without this pass, a preserved `in-progress` row (a repo NOT in this
|
|
1014
|
+
// update) is copied forward FOREVER — a crashed/never-closed session's row
|
|
1015
|
+
// never flips even after its heartbeat has aged well past the lock's TTL,
|
|
1016
|
+
// because `collectRows` only re-derives status for repos actually IN
|
|
1017
|
+
// `repoList`. Re-derive staleness for every preserved row here, BEFORE the
|
|
1018
|
+
// freshly-derived `rows` are upserted over it below (fresh data always
|
|
1019
|
+
// wins regardless of this pass — a live lock or an explicit-closed update
|
|
1020
|
+
// always takes precedence over the TTL flip).
|
|
1021
|
+
//
|
|
1022
|
+
// Board rows carry only a raw `heartbeat` string, never the lock's own
|
|
1023
|
+
// `ttl_hours` (that field is not part of the rendered board) — so this
|
|
1024
|
+
// reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
|
|
1025
|
+
// per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
|
|
1026
|
+
// Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
|
|
1027
|
+
// never crash on a malformed prior board).
|
|
1028
|
+
const nowMs = now instanceof Date ? now.getTime() : Date.now();
|
|
1029
|
+
const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
|
|
1030
|
+
const staleRederivedRows = new Map();
|
|
1031
|
+
for (const [key, row] of preservedRows) {
|
|
1032
|
+
if (row.status === STATUS_IN_PROGRESS) {
|
|
1033
|
+
const heartbeatMs = Date.parse(row.heartbeat ?? '');
|
|
1034
|
+
if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
|
|
1035
|
+
staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1031
1038
|
}
|
|
1039
|
+
staleRederivedRows.set(key, row);
|
|
1032
1040
|
}
|
|
1033
|
-
staleRederivedRows.set(key, row);
|
|
1034
|
-
}
|
|
1035
1041
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1042
|
+
// Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
|
|
1043
|
+
// update, then upsert the freshly-derived rows over them so repeated writes
|
|
1044
|
+
// stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
|
|
1045
|
+
// same slot — that is what collapses a live row over a stale preserved one.
|
|
1046
|
+
//
|
|
1047
|
+
// Dual-key upsert (#871). A naive switch from the folded name to the path
|
|
1048
|
+
// key would make the two key spaces DISJOINT: the fresh row would never
|
|
1049
|
+
// overwrite the legacy row, the legacy row would become immortal (the sweep
|
|
1050
|
+
// skips `frei` candidates and the TTL pass only rewrites `status`, never
|
|
1051
|
+
// removes a row), and the board would grow a permanent duplicate per repo.
|
|
1052
|
+
// So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
|
|
1053
|
+
// the legacy name slot for the same folded name — one board write converts
|
|
1054
|
+
// the row, and the migration is complete for that repo.
|
|
1055
|
+
const merged = new Map(staleRederivedRows);
|
|
1056
|
+
for (const row of rows) {
|
|
1057
|
+
const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
|
|
1058
|
+
if (row.key && !merged.has(slot)) {
|
|
1059
|
+
// First keyed write for this repo — take over its legacy row rather than
|
|
1060
|
+
// rendering a second one beside it.
|
|
1061
|
+
merged.delete(nameSlot(row.repo));
|
|
1062
|
+
}
|
|
1063
|
+
merged.set(slot, row);
|
|
1056
1064
|
}
|
|
1057
|
-
merged.set(slot, row);
|
|
1058
|
-
}
|
|
1059
1065
|
|
|
1060
|
-
|
|
1066
|
+
const content = renderBoard([...merged.values()], { now, createdIso });
|
|
1067
|
+
|
|
1068
|
+
return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// dry-run never touches disk (writeBoard guard 1) — so it must not create a
|
|
1072
|
+
// lock file in the operator's vault either. Nothing to serialise.
|
|
1073
|
+
if (dryRun) return await mergeAndWrite();
|
|
1074
|
+
|
|
1075
|
+
// The lock outcome is diagnostic, and until now it went nowhere: `withBoardLock`
|
|
1076
|
+
// has exposed `onLockOutcome` since #1180, and NO production caller passed one
|
|
1077
|
+
// (measured 2026-09-02: `grep -rn onLockOutcome scripts hooks` → board-lock.mjs
|
|
1078
|
+
// and its test, nothing else). So the two states that silently weaken the mutex —
|
|
1079
|
+
// a fail-open unlocked write, and a stale-override that can override a LIVE
|
|
1080
|
+
// writer (see board-lock's DEFAULT_STALE_MS § CEILING) — were unobservable in
|
|
1081
|
+
// aggregate, which is exactly what the revisit trigger needs. Capture it here and
|
|
1082
|
+
// ride it out on the ONE board_written event rather than adding a second event.
|
|
1083
|
+
let lockOutcome;
|
|
1084
|
+
const acquireStartedAt = Date.now();
|
|
1085
|
+
const inner = await withBoardLock(expandedVault, mergeAndWrite, {
|
|
1086
|
+
onLockOutcome: (o) => {
|
|
1087
|
+
// Called exactly once, strictly BEFORE `fn` — so the elapsed time is the
|
|
1088
|
+
// acquire wait, not the merge. `lockPath` is deliberately DROPPED: it is
|
|
1089
|
+
// `<vault>/.orchestrator/board.lock` under $HOME, i.e. the CP1 (OS username)
|
|
1090
|
+
// shape `telemetrySafePath` exists to keep out of the payload.
|
|
1091
|
+
lockOutcome = {
|
|
1092
|
+
locked: o?.locked === true,
|
|
1093
|
+
...(typeof o?.reason === 'string' ? { reason: o.reason } : {}),
|
|
1094
|
+
...(typeof o?.staleOverride === 'string' ? { stale_override: o.staleOverride } : {}),
|
|
1095
|
+
waited_ms: Date.now() - acquireStartedAt,
|
|
1096
|
+
};
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1061
1099
|
|
|
1062
|
-
return
|
|
1100
|
+
return lockOutcome === undefined ? inner : { ...inner, lock: lockOutcome };
|
|
1063
1101
|
}
|
|
1064
1102
|
|
|
1065
1103
|
/**
|
|
@@ -1095,7 +1133,7 @@ export async function mirrorBoard(opts = {}) {
|
|
|
1095
1133
|
// throws exactly as it did before this wrapper existed.
|
|
1096
1134
|
const { repoRoot, caller = 'mirrorBoard', reposSwept } = opts;
|
|
1097
1135
|
|
|
1098
|
-
const { result, rows } = await mirrorBoardInner(opts);
|
|
1136
|
+
const { result, rows, lock } = await mirrorBoardInner(opts);
|
|
1099
1137
|
|
|
1100
1138
|
await emitBoardEvent({
|
|
1101
1139
|
repoRoot,
|
|
@@ -1105,6 +1143,7 @@ export async function mirrorBoard(opts = {}) {
|
|
|
1105
1143
|
rows,
|
|
1106
1144
|
reposSwept,
|
|
1107
1145
|
durationMs: Date.now() - startedAt,
|
|
1146
|
+
lock,
|
|
1108
1147
|
});
|
|
1109
1148
|
|
|
1110
1149
|
return result;
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
30
30
|
import { readFile } from 'node:fs/promises';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
-
import os from 'node:os';
|
|
33
32
|
|
|
34
33
|
import { emitEvent, sessionAttribution } from '../events.mjs';
|
|
35
34
|
import { parseStateMd, parseMissionStatus } from '../state-md.mjs';
|
|
@@ -43,6 +42,7 @@ import { matchesModuloRedaction } from '../vault-mirror/process.mjs';
|
|
|
43
42
|
import { readConfigFile, parseSessionConfig } from '../config.mjs';
|
|
44
43
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
45
44
|
import { createSecretValueMasker } from '../secret-masker.mjs';
|
|
45
|
+
import { expandTilde } from '../common.mjs';
|
|
46
46
|
|
|
47
47
|
/** Frontmatter sentinel that identifies generator-owned narrative files. */
|
|
48
48
|
export const GENERATOR_MARKER = 'session-orchestrator-vault-status-narrative@1';
|
|
@@ -63,23 +63,6 @@ const SECTION_TITLES = {
|
|
|
63
63
|
|
|
64
64
|
// ── Raw section extraction ──────────────────────────────────────────────────────
|
|
65
65
|
|
|
66
|
-
/**
|
|
67
|
-
* Expand a leading `~` to the current user's home directory.
|
|
68
|
-
*
|
|
69
|
-
* NOTE: deferred shared-helper extraction. The same `expandHome` pattern lives in
|
|
70
|
-
* other vault-status modules; W2 forbids introducing a shared new file, so this is
|
|
71
|
-
* inlined here. Consolidate into a shared util in a follow-up wave.
|
|
72
|
-
*
|
|
73
|
-
* @param {string} p
|
|
74
|
-
* @returns {string}
|
|
75
|
-
*/
|
|
76
|
-
function expandHome(p) {
|
|
77
|
-
if (typeof p !== 'string' || p.length === 0) return p;
|
|
78
|
-
if (p === '~') return os.homedir();
|
|
79
|
-
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
80
|
-
return p;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
66
|
/**
|
|
84
67
|
* Match an ATX markdown heading line. Returns `{ level, text }` or null.
|
|
85
68
|
*
|
|
@@ -795,7 +778,7 @@ async function runNarrativeMirror(opts) {
|
|
|
795
778
|
return { result: { action: 'skipped-vault-disabled' } };
|
|
796
779
|
}
|
|
797
780
|
|
|
798
|
-
const vaultDir = path.resolve(
|
|
781
|
+
const vaultDir = path.resolve(expandTilde(rawVaultDir));
|
|
799
782
|
const candidateSlug = subjectToSlug(repoName) || 'unknown';
|
|
800
783
|
// Loose-match against existing 01-projects/ folders before minting a new
|
|
801
784
|
// slug (issue #829 Finding 3) — see resolveLooseSlug for the ambiguity
|
|
@@ -165,7 +165,7 @@ const SAFE_RUN_ID = /^[A-Za-z0-9._-]+$/;
|
|
|
165
165
|
* @param {unknown} runId
|
|
166
166
|
* @returns {boolean} true when the id is safe to use as a path segment.
|
|
167
167
|
*/
|
|
168
|
-
function isSafeRunId(runId) {
|
|
168
|
+
export function isSafeRunId(runId) {
|
|
169
169
|
const s = String(runId ?? '');
|
|
170
170
|
if (s === '.' || s === '..') return false; // admitted by SAFE_RUN_ID, still an escape
|
|
171
171
|
return SAFE_RUN_ID.test(s);
|
|
@@ -330,7 +330,7 @@ export const MEASUREMENT_EXCLUDES = Object.freeze(['node_modules']);
|
|
|
330
330
|
* @returns {Promise<{capture: string, exitCode: number|null, signal: string|null,
|
|
331
331
|
* timerFired: boolean, killSignals: string[], errorCode: string|null}>}
|
|
332
332
|
*/
|
|
333
|
-
function runChild(cmd, args, options, { spawnFn, timeoutMs, killGraceMs }) {
|
|
333
|
+
export function runChild(cmd, args, options, { spawnFn, timeoutMs, killGraceMs }) {
|
|
334
334
|
return new Promise((resolve) => {
|
|
335
335
|
/** @type {string[]} */
|
|
336
336
|
const killSignals = [];
|