wicked-crew 0.8.3 → 0.8.4

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.
@@ -144,7 +144,7 @@ import { LiveGenerations } from './live-generations.js';
144
144
  import { discoverLivePluginDetailed, GARDEN_INSTALL_COMMAND, gitStateOf, PluginSourceSymlinkError, REQUIRED_GARDEN_VERSION, } from './plugin-source.js';
145
145
  import { existsIn, extractPluginRootRefs, extractRelativeRefs, looksBinary, PORTABILITY_EVIDENCE_CAP, PORTABILITY_REASONS, PORTABILITY_RULES_IDENTITY, portabilityEvidenceOf, portabilityIssuesOf, portabilityReasonsOf, resolvePluginRootRef, resolveRelativeRef, } from './refs.js';
146
146
  import { CURRENT_TMP_PREFIX, STAGING_PREFIX } from './root-names.js';
147
- import { assertNoSymlinkComponents, copyFiles, EntrySwappedError, fingerprintTree, hashFileSet, hashTree, impliedDirs, isCarriedFile, makeTreeReadOnly, pruneEmptyDirs, readFileNoFollow, removeTreeForce, sha256Hex, SKIP_DIR_NAMES, SymlinkComponentError, walkEntries, walkFiles, walkTree, writeFileAtomic, } from './tree.js';
147
+ import { assertNoSymlinkComponents, budgetPacer, copyFiles, copyFilesSteps, drain, EntrySwappedError, fingerprintTreeSteps, hashFileSet, hashTreeSteps, impliedDirs, isCarriedFile, makeTreeReadOnly, makeTreeReadOnlySteps, paced, pruneEmptyDirs, readFileNoFollow, removeTreeForce, removeTreeForcePaced, sha256Hex, SKIP_DIR_NAMES, SymlinkComponentError, walkEntriesSteps, walkFiles, walkTree, walkTreeSteps, writeFileAtomic, } from './tree.js';
148
148
  /** The root-level names the store creates live in ONE table (`root-names.ts`, design v3.5 §2) — re-exported for the tests that observe the flip. */
149
149
  export { CURRENT_TMP_PREFIX } from './root-names.js';
150
150
  import { baselineVenvDir, UV_CACHE_DIRNAME, VENV_READY_MARKER } from './venv.js';
@@ -512,6 +512,7 @@ export class SkillsStore {
512
512
  live = new LiveGenerations();
513
513
  /** The one publish that may run at a time (module header) — `null` when none is in flight. */
514
514
  publishInFlight = null;
515
+ makePacer;
515
516
  /** One provisioning per baseline hash: a concurrent caller awaits the in-flight one. */
516
517
  venvInFlight = new Map();
517
518
  /**
@@ -554,6 +555,7 @@ export class SkillsStore {
554
555
  });
555
556
  this.now = opts.now ?? (() => new Date().toISOString());
556
557
  this.warn = opts.warn ?? ((m) => console.warn(m));
558
+ this.makePacer = opts.pacer ?? (() => budgetPacer());
557
559
  }
558
560
  // ── Paths ─────────────────────────────────────────────────────────────────────────────────
559
561
  get root() {
@@ -851,6 +853,18 @@ export class SkillsStore {
851
853
  if (expected !== m.revision)
852
854
  throw new RevisionMismatchError(expected, m.revision);
853
855
  }
856
+ /**
857
+ * A MUTATION while a publish runs is refused before it touches anything (crew#852): the publish
858
+ * now yields to the event loop between tree steps, so an API write could otherwise commit between
859
+ * its validation and its commit — the publish would then either fail its own CAS or stage content
860
+ * its manifest no longer describes. Same envelope as a second publish (`publish-in-flight`, 2xx,
861
+ * nothing written — re-read and retry); before the pacing a write in that window queued behind
862
+ * the blocked loop and failed its CAS after the publish landed. Reads are never refused.
863
+ */
864
+ refuseDuringPublish() {
865
+ if (this.publishInFlight !== null)
866
+ throw new SkillsPublishInFlightError(this.revision());
867
+ }
854
868
  // ── `current` ─────────────────────────────────────────────────────────────────────────────
855
869
  /**
856
870
  * The published snapshot `current` resolves to, VERIFIED (module header), or `null` when there
@@ -861,6 +875,18 @@ export class SkillsStore {
861
875
  * re-derived each time it is handed out.
862
876
  */
863
877
  currentSnapshot() {
878
+ return drain(this.currentSnapshotSteps());
879
+ }
880
+ /**
881
+ * `currentSnapshot()` on the event loop (crew#852): the same verification, step for step, with
882
+ * its walk, fingerprint and hash paced so a request landing mid-verification is answered within
883
+ * one slice instead of after the whole generation. The route layer's publish and `GET /skills`
884
+ * drive this one; boot and the synchronous readers keep `currentSnapshot()`.
885
+ */
886
+ async currentSnapshotPaced(pacer) {
887
+ return paced(this.currentSnapshotSteps(), pacer);
888
+ }
889
+ *currentSnapshotSteps() {
864
890
  this.assertRootIdentity(); // never answered through a root that changed identity (codex round 4)
865
891
  const link = this.currentLink();
866
892
  let target;
@@ -874,7 +900,7 @@ export class SkillsStore {
874
900
  throw new SkillsCurrentInvalidError(link, 'it is not a symbolic link');
875
901
  throw err;
876
902
  }
877
- return this.verifyCurrent(link, target);
903
+ return yield* this.verifyCurrentSteps(link, target);
878
904
  }
879
905
  /**
880
906
  * The skill rows of the VERIFIED current generation — what the engine's intake admission judges
@@ -887,12 +913,16 @@ export class SkillsStore {
887
913
  const current = this.currentSnapshot();
888
914
  if (current === null)
889
915
  return null;
916
+ return this.snapshotSkillsOf(current);
917
+ }
918
+ /** `currentSnapshotSkills()` for a generation the caller holds VERIFIED already (the ladder's own, just exported) — the metadata read alone, no re-walk (crew#852). */
919
+ snapshotSkillsOf(current) {
890
920
  const meta = this.readSnapshotMetadata(current.path);
891
921
  if (typeof meta === 'string')
892
922
  return null;
893
923
  return { gen: current.gen, skills: meta.parsed.skills.map((row) => row.name) };
894
924
  }
895
- verifyCurrent(link, target) {
925
+ *verifyCurrentSteps(link, target) {
896
926
  const invalid = (detail) => {
897
927
  throw new SkillsCurrentInvalidError(link, detail);
898
928
  };
@@ -973,7 +1003,7 @@ export class SkillsStore {
973
1003
  // moves during verification pairs the verified result with a fingerprint that no longer
974
1004
  // matches, so the next read re-verifies) and once more AFTER it; the result is memoised only
975
1005
  // when both agree.
976
- const fingerprint = fingerprintTree(real);
1006
+ const fingerprint = yield* fingerprintTreeSteps(real);
977
1007
  const memo = this.verifiedCurrent;
978
1008
  if (memo !== null &&
979
1009
  fingerprint !== null &&
@@ -989,7 +1019,7 @@ export class SkillsStore {
989
1019
  return invalid(linkProblem);
990
1020
  return memo.result;
991
1021
  }
992
- const tree = walkTree(real);
1022
+ const tree = yield* walkTreeSteps(real);
993
1023
  const special = tree.others[0];
994
1024
  if (special !== undefined)
995
1025
  return invalid(`${special.rel} is neither a file, a directory nor a symlink — a published generation carries no special nodes`);
@@ -1001,7 +1031,7 @@ export class SkillsStore {
1001
1031
  if (prunedDir !== undefined) {
1002
1032
  return invalid(`unexpected directory ${prunedDir} — a published generation never carries a ${posix.basename(prunedDir)} directory (publish copies no pruned directory; the environment is the .venv link at the generation root): the immutable snapshot was modified`);
1003
1033
  }
1004
- const hash = this.snapshotHash(tree);
1034
+ const hash = yield* this.snapshotHashSteps(tree);
1005
1035
  if (hash !== parsed.contentHash) {
1006
1036
  return invalid(`content hash mismatch — snapshot.json records ${parsed.contentHash}, the tree hashes ${hash}: the immutable snapshot was modified`);
1007
1037
  }
@@ -1014,14 +1044,14 @@ export class SkillsStore {
1014
1044
  const running = { ...PORTABILITY_RULES_IDENTITY };
1015
1045
  const recorded = parsed.rulesVersion === undefined || parsed.rulesSha256 === undefined ? null : { version: parsed.rulesVersion, sha256: parsed.rulesSha256 };
1016
1046
  const stale = recorded === null || recorded.version !== running.version || recorded.sha256 !== running.sha256;
1017
- const rows = this.snapshotRowsProblem(parsed, tree, stale);
1047
+ const rows = yield* this.snapshotRowsProblemSteps(parsed, tree, stale);
1018
1048
  if ('problem' in rows)
1019
1049
  return invalid(rows.problem);
1020
1050
  const linkProblem = this.snapshotLinkProblem(real, tree.links, parsed);
1021
1051
  if (linkProblem !== null)
1022
1052
  return invalid(linkProblem);
1023
1053
  const result = { gen: parsed.gen, path: real, rules: { recorded, running, stale }, drift: rows.drift };
1024
- const after = fingerprintTree(real);
1054
+ const after = yield* fingerprintTreeSteps(real);
1025
1055
  this.verifiedCurrent =
1026
1056
  fingerprint !== null && after === fingerprint
1027
1057
  ? { gen: parsed.gen, contentHash: parsed.contentHash, snapshotHash: meta.rawSha, fingerprint, links: [...tree.links], result }
@@ -1045,7 +1075,7 @@ export class SkillsStore {
1045
1075
  * else (a missing SKILL.md, a kind claim, the view shape against the RECORDED rows) is judged the
1046
1076
  * same under any rules: none of it depends on the rule table.
1047
1077
  */
1048
- snapshotRowsProblem(parsed, tree, staleRules) {
1078
+ *snapshotRowsProblemSteps(parsed, tree, staleRules) {
1049
1079
  const problem = (detail) => ({ problem: detail });
1050
1080
  const drift = [];
1051
1081
  const files = tree.files;
@@ -1061,6 +1091,7 @@ export class SkillsStore {
1061
1091
  if (skillMd === undefined)
1062
1092
  return problem(`skill row ${row.name} names ${row.dir}, but the generation carries no ${row.dir}/SKILL.md`);
1063
1093
  const fm = parseFrontmatter(readFileNoFollow(skillMd.abs).toString('utf8'));
1094
+ yield;
1064
1095
  if (!fm.ok)
1065
1096
  return problem(`${row.dir}/SKILL.md frontmatter does not parse (${fm.reason}) — its row cannot be re-derived`);
1066
1097
  const kind = skillKindOf(fm.fields);
@@ -1072,6 +1103,7 @@ export class SkillsStore {
1072
1103
  if (!f.rel.startsWith(prefix) || owningSkillDir(f.rel, dirs) !== row.dir)
1073
1104
  continue;
1074
1105
  const buf = readFileNoFollow(f.abs);
1106
+ yield;
1075
1107
  if (looksBinary(buf))
1076
1108
  continue;
1077
1109
  for (const hit of portabilityIssuesOf(buf.toString('utf8'), { fileRel: f.rel, skillDir: row.dir, skillDirs: dirs, exists }))
@@ -1132,7 +1164,10 @@ export class SkillsStore {
1132
1164
  }
1133
1165
  /** Hash over a snapshot tree — files (`snapshot.json` excluded), link entries (path + link text) AND directory entries (codex round 9: an extra empty directory changes it). */
1134
1166
  snapshotHash(tree) {
1135
- return hashTree(tree.files.filter((f) => f.rel !== SNAPSHOT_MANIFEST_FILENAME), tree.links, tree.dirs);
1167
+ return drain(this.snapshotHashSteps(tree));
1168
+ }
1169
+ *snapshotHashSteps(tree) {
1170
+ return yield* hashTreeSteps(tree.files.filter((f) => f.rel !== SNAPSHOT_MANIFEST_FILENAME), tree.links, tree.dirs);
1136
1171
  }
1137
1172
  /** The link text publish writes for a snapshot's `.venv`: relative on POSIX, the absolute target for a Windows junction. */
1138
1173
  venvLinkText(baseline) {
@@ -1443,6 +1478,10 @@ export class SkillsStore {
1443
1478
  * covers it, and no other pruned name is linked from anywhere.
1444
1479
  */
1445
1480
  baselineProblem(hash) {
1481
+ return drain(this.baselineProblemSteps(hash));
1482
+ }
1483
+ /** `baselineProblem` as steps (crew#852): it walks AND re-hashes the whole bundle — the walk's yields, then one per file hashed. */
1484
+ *baselineProblemSteps(hash) {
1446
1485
  const dir = this.baselineDir(hash);
1447
1486
  const st = lstatOrNull(dir);
1448
1487
  if (st === null)
@@ -1453,7 +1492,7 @@ export class SkillsStore {
1453
1492
  return `${dir} is not a directory`;
1454
1493
  let tree;
1455
1494
  try {
1456
- tree = walkTree(dir);
1495
+ tree = yield* walkTreeSteps(dir);
1457
1496
  }
1458
1497
  catch (err) {
1459
1498
  return `${dir} cannot be walked (${err instanceof Error ? err.message : String(err)})`;
@@ -1464,7 +1503,7 @@ export class SkillsStore {
1464
1503
  const special = tree.others[0];
1465
1504
  if (special !== undefined)
1466
1505
  return `${dir} carries ${special.rel}, which is neither a file nor a directory`;
1467
- const actual = hashFileSet(tree.files);
1506
+ const actual = yield* hashTreeSteps(tree.files, []);
1468
1507
  if (actual !== hash)
1469
1508
  return `${dir} hashes to ${actual}, not to its name — a bundle file was modified, added or removed`;
1470
1509
  return null;
@@ -1774,6 +1813,10 @@ export class SkillsStore {
1774
1813
  // ── Scanning ──────────────────────────────────────────────────────────────────────────────
1775
1814
  /** Every managed file under `effective/`, hashed. */
1776
1815
  scanEffective() {
1816
+ return drain(this.scanEffectiveSteps());
1817
+ }
1818
+ /** `scanEffective` as steps (crew#852): the walk's yields, then one per file hashed. */
1819
+ *scanEffectiveSteps() {
1777
1820
  this.assertRootIdentity();
1778
1821
  // ONE walker classifies every entry (tree.ts `walkEntries`; codex round 9): the files are hashed,
1779
1822
  // the links and special nodes are handed to the validation to refuse by name, the directories are
@@ -1782,9 +1825,16 @@ export class SkillsStore {
1782
1825
  // `node_modules/`, `.venv/` or `__pycache__/` is in `links` / `others` (marked `pruned`) for the
1783
1826
  // validation to refuse; the files and directories beneath one stay out of the scan — never
1784
1827
  // carried, never hashed, exactly as before.
1785
- const entries = walkEntries(this.effectiveDir());
1828
+ const entries = yield* walkEntriesSteps(this.effectiveDir());
1829
+ const files = [];
1830
+ for (const e of entries) {
1831
+ if (!isCarriedFile(e))
1832
+ continue;
1833
+ files.push({ rel: e.rel, abs: e.abs, sha: sha256Hex(readFileNoFollow(e.abs)) });
1834
+ yield;
1835
+ }
1786
1836
  return {
1787
- files: entries.filter(isCarriedFile).map((e) => ({ rel: e.rel, abs: e.abs, sha: sha256Hex(readFileNoFollow(e.abs)) })),
1837
+ files,
1788
1838
  links: entries.filter((e) => e.kind === 'symlink'),
1789
1839
  others: entries.filter((e) => e.kind === 'other'),
1790
1840
  dirs: entries.filter((e) => e.kind === 'dir' && e.pruned === null).map((e) => e.rel),
@@ -1938,6 +1988,10 @@ export class SkillsStore {
1938
1988
  * blocking; a mutation on ANOTHER skill carries it as a warning (that mutation did land).
1939
1989
  */
1940
1990
  recomputeDerived(m) {
1991
+ return drain(this.recomputeDerivedSteps(m));
1992
+ }
1993
+ /** `recomputeDerived` as steps (crew#852): it reads every file of every skill for the portability scan — one `yield` per file read, one per skill. */
1994
+ *recomputeDerivedSteps(m) {
1941
1995
  const refused = [];
1942
1996
  const catalogMd = new Map();
1943
1997
  const byOwner = this.recordsByOwner(m);
@@ -1996,6 +2050,7 @@ export class SkillsStore {
1996
2050
  refused.push(this.pathFinding(err, name, rel));
1997
2051
  continue;
1998
2052
  }
2053
+ yield;
1999
2054
  if (buf === null || looksBinary(buf))
2000
2055
  continue;
2001
2056
  for (const hit of portabilityIssuesOf(buf.toString('utf8'), ctx(rel)))
@@ -2004,6 +2059,7 @@ export class SkillsStore {
2004
2059
  const reasons = portabilityReasonsOf(hits);
2005
2060
  entry.portable = reasons.length === 0;
2006
2061
  entry.portability = { portable: entry.portable, reasons, evidence: portabilityEvidenceOf(hits) };
2062
+ yield;
2007
2063
  }
2008
2064
  // A DIRECTLY registered reference is core regardless of its readability (design v3.5 §5; codex
2009
2065
  // round 8): a skill a workflow names by `skill_ref` keeps `core: true` when its `SKILL.md` is
@@ -2290,6 +2346,7 @@ export class SkillsStore {
2290
2346
  const entry = m.skills[name];
2291
2347
  if (entry === undefined)
2292
2348
  throw new UnknownSkillError(name);
2349
+ this.refuseDuringPublish();
2293
2350
  this.assertRevision(m, expectedRevision);
2294
2351
  return entry;
2295
2352
  }
@@ -2507,6 +2564,7 @@ export class SkillsStore {
2507
2564
  /** Write one root support file (`scripts/`, `schemas/`, `.claude-plugin/`, …) — always a warning. */
2508
2565
  writeSupport(rawRel, content, expectedRevision) {
2509
2566
  const m = this.manifest();
2567
+ this.refuseDuringPublish();
2510
2568
  this.assertRevision(m, expectedRevision);
2511
2569
  let target;
2512
2570
  try {
@@ -2742,6 +2800,7 @@ export class SkillsStore {
2742
2800
  /** Add a user skill at `skills/<name minus the prefix>` — staged, then swapped in (`swapOwnFiles`). */
2743
2801
  add(name, files, expectedRevision) {
2744
2802
  const m = this.manifest();
2803
+ this.refuseDuringPublish();
2745
2804
  this.assertRevision(m, expectedRevision);
2746
2805
  const findings = this.addGuards(m, name, files);
2747
2806
  if (verdictOf(findings) === 'blocked')
@@ -2920,6 +2979,7 @@ export class SkillsStore {
2920
2979
  */
2921
2980
  refreshBaseline(expectedRevision) {
2922
2981
  const m = this.manifest();
2982
+ this.refuseDuringPublish();
2923
2983
  this.assertRevision(m, expectedRevision);
2924
2984
  const source = this.requireSource('no installed wicked-garden plugin found to refresh from (neither the marketplace cache nor the installer-managed copy)');
2925
2985
  const previous = m.baseline;
@@ -3249,18 +3309,34 @@ export class SkillsStore {
3249
3309
  const v = this.validate(m);
3250
3310
  return { verdict: verdictOf(v.findings), findings: v.findings, revision: m.revision };
3251
3311
  }
3312
+ /** `analyze()` on the event loop (crew#852): the same whole-tree validation, its scan paced. Nothing is written either way. */
3313
+ async analyzePaced(pacer) {
3314
+ const m = this.manifest();
3315
+ const v = await paced(this.validateSteps(m), pacer);
3316
+ return { verdict: verdictOf(v.findings), findings: v.findings, revision: m.revision };
3317
+ }
3252
3318
  /**
3253
3319
  * ONE publish at a time (module header): a concurrent call is refused with
3254
3320
  * `SkillsPublishInFlightError` (the route's 409) rather than queued — the caller's revision would
3255
3321
  * be stale by the time a queued publish ran.
3256
3322
  */
3257
- async publish(expectedRevision) {
3323
+ async publish(expectedRevision, afterCommit) {
3258
3324
  if (this.publishInFlight !== null)
3259
3325
  throw new SkillsPublishInFlightError(this.revision());
3260
- const run = this.publishSerialized(expectedRevision);
3261
- this.publishInFlight = run;
3326
+ const pacer = this.makePacer();
3327
+ const run = this.publishSerialized(expectedRevision, pacer);
3328
+ // `afterCommit` (the route's export: verifying the new generation and handing it to the engine)
3329
+ // runs INSIDE the in-flight window (codex r1 on #853): it is paced, so the loop turns while it
3330
+ // verifies, and a second publish admitted in that window could commit a newer generation that
3331
+ // the first request would then export over. Held here, that publish is `publish-in-flight`
3332
+ // and `isPublishing()` is true for the whole request.
3333
+ const whole = afterCommit === undefined ? run : run.then(async (result) => {
3334
+ await afterCommit(result, pacer);
3335
+ return result;
3336
+ });
3337
+ this.publishInFlight = whole;
3262
3338
  try {
3263
- return await run;
3339
+ return await whole;
3264
3340
  }
3265
3341
  finally {
3266
3342
  this.publishInFlight = null;
@@ -3290,9 +3366,20 @@ export class SkillsStore {
3290
3366
  * `snapshots/<gen>/` (staging + rename, never over an existing generation) with the generated
3291
3367
  * views, LOCK it read-only, COMMIT the manifest (venv state included), flip `current`, and reap
3292
3368
  * generations / baselines nothing references. A `blocked` verdict persists nothing — the caller's
3293
- * revision stays valid. Everything after the one await is synchronous: no interleaving.
3369
+ * revision stays valid.
3370
+ *
3371
+ * Nothing here holds the event loop for longer than one slice (crew#852): every walk, hash, copy
3372
+ * and lock over the tree runs as PACED steps (tree.ts `paced` / `budgetPacer`), so `/health`, the
3373
+ * run listing and the stall watchdog keep answering while a 15k-file catalogue publishes. The
3374
+ * loop therefore turns many times between the CAS and the commit — a read in that window sees the
3375
+ * previous generation and the manifest as it was (nothing is written before the commit); a WRITE
3376
+ * that commits in that window moves the revision, and the commit below re-checks the root identity
3377
+ * and the revision first: a moved revision removes the staged generation (no manifest, no
3378
+ * generation) and fails the caller's CAS exactly as a stale `expectedRevision` does. The
3379
+ * published-generation semantics are unchanged: `gen` increments once, the manifest lands before
3380
+ * `current` flips, a reader sees the old generation or the new one, never half of either.
3294
3381
  */
3295
- async publishSerialized(expectedRevision) {
3382
+ async publishSerialized(expectedRevision, pacer) {
3296
3383
  // Refuse a root that changed identity (codex round 4) or a symlinked storage ancestor (codex
3297
3384
  // round 3) BEFORE anything is provisioned, staged or copied: a `snapshots -> /outside` would
3298
3385
  // take the first copy out of the store.
@@ -3311,7 +3398,7 @@ export class SkillsStore {
3311
3398
  // The baseline must be the bundle its name claims BEFORE anything is provisioned in it (codex
3312
3399
  // round 7): a corrupt baseline is `baseline-corrupt`, blocking — nothing provisioned, nothing
3313
3400
  // written. `validate` re-derives the same hash AFTER the provisioner ran.
3314
- const preProblem = this.baselineProblem(pre.baseline);
3401
+ const preProblem = await paced(this.baselineProblemSteps(pre.baseline), pacer);
3315
3402
  if (preProblem !== null) {
3316
3403
  return { verdict: 'blocked', findings: [this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${pre.baseline}`, preProblem)], revision: pre.revision, snapshot: null };
3317
3404
  }
@@ -3326,13 +3413,17 @@ export class SkillsStore {
3326
3413
  // The `unchanged` fast path (crew#547 items 1-2 / DES-L6 PR-L6-1 (b)): BEFORE the slow step, the
3327
3414
  // SAME content hash the slow path would compute is derived from the effective tree; equal to the
3328
3415
  // current generation's — and that generation still verifies under the running rules — means
3329
- // there is nothing to publish: nothing awaited, nothing written, no generation minted.
3416
+ // there is nothing to publish: nothing written, no generation minted. The steps are paced
3417
+ // (crew#852), so the `unchanged` answer is re-checked against the revision on disk: a write that
3418
+ // landed while the tree was hashed makes the caller's revision stale, exactly as before the check.
3330
3419
  const venvDir0 = baselineVenvDir(this.baselineDir(pre.baseline));
3331
3420
  const ready0 = this.venvReady(venvDir0);
3332
- const fast = this.unchangedPublish(pre, ready0);
3421
+ const fast = await paced(this.unchangedPublishSteps(pre, ready0), pacer);
3333
3422
  t.validate += fast.validateMs;
3334
3423
  t.hash += fast.hashMs;
3335
3424
  if (fast.result !== null) {
3425
+ this.assertRootUnchanged(bound, expectedRevision);
3426
+ this.assertRevision(this.manifest(), expectedRevision);
3336
3427
  timing(ready0 ? 'ready' : 'skipped', ` · unchanged (gen ${fast.result.snapshot?.gen ?? '?'})`);
3337
3428
  return fast.result;
3338
3429
  }
@@ -3346,7 +3437,7 @@ export class SkillsStore {
3346
3437
  const m = this.manifest();
3347
3438
  this.assertRevision(m, expectedRevision);
3348
3439
  const tValidate = performance.now();
3349
- const v = this.validate(m);
3440
+ const v = await paced(this.validateSteps(m), pacer);
3350
3441
  t.validate += performance.now() - tValidate; // validated twice on a CHANGED publish (accepted; the line shows the cost)
3351
3442
  if (venv === 'failed') {
3352
3443
  v.findings.push(finding('venv-failed', 'blocking', "the baseline's shared read-only Python environment is REQUIRED (the bundle carries a pyproject.toml; skills `uv run` from the plugin root): a snapshot without it hands every worker a plugin whose scripts cannot start. Install uv, fix the sync error the log names, and publish again", `uv sync did not produce ${baselineVenvDir(this.baselineDir(m.baseline))} (see the daemon log)`, {}));
@@ -3369,14 +3460,14 @@ export class SkillsStore {
3369
3460
  // later verification hash the directories they WALK, so an extra directory — empty or not — is a
3370
3461
  // mismatch, never an invisible passenger.
3371
3462
  const tHash = performance.now();
3372
- const contentHash = hashTree(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
3463
+ const contentHash = await paced(hashTreeSteps(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel))), pacer);
3373
3464
  t.hash += performance.now() - tHash;
3374
3465
  const tStage = performance.now();
3375
3466
  const snapshots = this.snapshotsDir();
3376
3467
  mkdirSync(snapshots, { recursive: true });
3377
3468
  this.sweepStaging(snapshots);
3378
3469
  const staging = join(snapshots, `${STAGING_PREFIX}${randomBytes(6).toString('hex')}`);
3379
- copyFiles(allFiles, staging);
3470
+ await paced(copyFilesSteps(allFiles, staging), pacer);
3380
3471
  const record = m.baselines[m.baseline];
3381
3472
  const snapshot = {
3382
3473
  gen,
@@ -3417,7 +3508,7 @@ export class SkillsStore {
3417
3508
  this.symlink(venvLink.text, join(staging, VENV_LINKNAME), venvLink.absTarget);
3418
3509
  // Re-walk (lstat) and re-hash the staged generation AFTER the copy and BEFORE the rename (design
3419
3510
  // v3.5 §3): files and the one permitted link must hash to the content it was copied from.
3420
- const stagedHash = this.snapshotHash(walkTree(staging));
3511
+ const stagedHash = await paced(this.snapshotHashSteps(await paced(walkTreeSteps(staging), pacer)), pacer);
3421
3512
  if (stagedHash !== contentHash) {
3422
3513
  removeTreeForce(staging);
3423
3514
  throw new SkillsPublishError(`the staged generation hashes to ${stagedHash}, not to the content it was copied from (${contentHash}) — modified between copy and rename; nothing published`);
@@ -3431,12 +3522,24 @@ export class SkillsStore {
3431
3522
  // Immutable by contract, and now by mode bits: a published generation is locked read-only
3432
3523
  // (verification by hash stays the authority — the lock is what makes an accidental edit fail).
3433
3524
  try {
3434
- makeTreeReadOnly(dest);
3525
+ await paced(makeTreeReadOnlySteps(dest), pacer);
3435
3526
  }
3436
3527
  catch (err) {
3437
3528
  removeTreeForce(dest);
3438
3529
  throw new SkillsPublishError(`could not lock ${dest} read-only: ${err instanceof Error ? err.message : String(err)}`);
3439
3530
  }
3531
+ // The paced steps let the loop turn (crew#852): the manifest about to be committed is the one
3532
+ // bound at the start, so it lands only through the same root and over the same revision — a
3533
+ // write that committed meanwhile fails this publish's CAS (the caller re-reads and retries) and
3534
+ // the generation it never came to own is removed, exactly like a commit that fails below.
3535
+ try {
3536
+ this.assertRootUnchanged(bound, expectedRevision);
3537
+ this.assertRevision(this.manifest(), expectedRevision);
3538
+ }
3539
+ catch (err) {
3540
+ removeTreeForce(dest);
3541
+ throw err;
3542
+ }
3440
3543
  // Manifest FIRST, then `current`: a crash in between leaves a committed generation `ensureReady`
3441
3544
  // finishes flipping to; the reverse order would leave a live `current` the manifest disowns.
3442
3545
  const included = new Set(v.snapshotFiles.map((f) => f.rel));
@@ -3465,9 +3568,9 @@ export class SkillsStore {
3465
3568
  this.flipCurrent(gen);
3466
3569
  this.live.published(gen);
3467
3570
  for (const g of retiredGens)
3468
- removeTreeForce(this.snapshotDir(g)); // locked read-only at publish
3571
+ await removeTreeForcePaced(this.snapshotDir(g), pacer); // locked read-only at publish
3469
3572
  for (const dir of prune.dirs)
3470
- removeTreeForce(dir);
3573
+ await removeTreeForcePaced(dir, pacer);
3471
3574
  t.stage = performance.now() - tStage;
3472
3575
  timing(venvLabel, ` · gen ${gen}`);
3473
3576
  return {
@@ -3494,7 +3597,7 @@ export class SkillsStore {
3494
3597
  * touch: a rollback reads the same manifest. A blocked validation is left to the slow path so the
3495
3598
  * findings (a `venv-failed` included) are reported exactly as before.
3496
3599
  */
3497
- unchangedPublish(pre, venvReady) {
3600
+ *unchangedPublishSteps(pre, venvReady) {
3498
3601
  const none = (validateMs = 0, hashMs = 0) => ({ result: null, validateMs, hashMs });
3499
3602
  const published = pre.published;
3500
3603
  if (published === null)
@@ -3511,13 +3614,13 @@ export class SkillsStore {
3511
3614
  else
3512
3615
  return none();
3513
3616
  const tValidate = performance.now();
3514
- const v = this.validate(pre);
3617
+ const v = yield* this.validateSteps(pre);
3515
3618
  const validateMs = performance.now() - tValidate;
3516
3619
  if (verdictOf(v.findings) === 'blocked')
3517
3620
  return none(validateMs);
3518
3621
  const tHash = performance.now();
3519
3622
  const allFiles = sortedRels([...v.snapshotFiles, ...this.viewFiles(v).files]);
3520
- const candidate = hashTree(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
3623
+ const candidate = yield* hashTreeSteps(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
3521
3624
  const hashMs = performance.now() - tHash;
3522
3625
  if (candidate !== published.contentHash)
3523
3626
  return none(validateMs, hashMs);
@@ -3525,7 +3628,7 @@ export class SkillsStore {
3525
3628
  return none(validateMs, hashMs);
3526
3629
  let current;
3527
3630
  try {
3528
- current = this.currentSnapshot();
3631
+ current = yield* this.currentSnapshotSteps();
3529
3632
  }
3530
3633
  catch {
3531
3634
  return none(validateMs, hashMs); // an unverifiable current generation is re-minted by the slow path
@@ -3559,8 +3662,21 @@ export class SkillsStore {
3559
3662
  viewFiles(v) {
3560
3663
  const files = [];
3561
3664
  const copilotSkills = [];
3562
- const byRel = new Map(v.snapshotFiles.map((f) => [f.rel, f]));
3563
3665
  const dirs = new Set(v.enabledSkills.map(({ entry }) => entry.dir));
3666
+ // ONE pass groups the file set by its owning skill dir (crew#852): the former per-skill scan of
3667
+ // every file was skills × files `owningSkillDir` calls — millions on the real catalogue, all on
3668
+ // the event loop. A file whose owner is `entry.dir` sits under `entry.dir/` by construction.
3669
+ const byOwner = new Map();
3670
+ for (const f of v.snapshotFiles) {
3671
+ const owner = owningSkillDir(f.rel, dirs);
3672
+ if (owner === null)
3673
+ continue;
3674
+ const group = byOwner.get(owner);
3675
+ if (group === undefined)
3676
+ byOwner.set(owner, [f]);
3677
+ else
3678
+ group.push(f);
3679
+ }
3564
3680
  for (const { name, entry } of v.enabledSkills) {
3565
3681
  if (!entry.portable)
3566
3682
  continue;
@@ -3571,10 +3687,8 @@ export class SkillsStore {
3571
3687
  }
3572
3688
  copilotSkills.push(name);
3573
3689
  const prefix = `${entry.dir}/`;
3574
- for (const [rel, f] of byRel) {
3575
- if (!rel.startsWith(prefix) || owningSkillDir(rel, dirs) !== entry.dir)
3576
- continue;
3577
- files.push({ rel: `${COPILOT_VIEW_SKILLS_REL}/${name}/${rel.slice(prefix.length)}`, abs: f.abs });
3690
+ for (const f of byOwner.get(entry.dir) ?? []) {
3691
+ files.push({ rel: `${COPILOT_VIEW_SKILLS_REL}/${name}/${f.rel.slice(prefix.length)}`, abs: f.abs });
3578
3692
  }
3579
3693
  }
3580
3694
  return { files: sortedRels(files), copilotSkills: copilotSkills.sort() };
@@ -3738,14 +3852,18 @@ export class SkillsStore {
3738
3852
  * commits it; analyze and a blocked publish drop it). Never touches `effective/`.
3739
3853
  */
3740
3854
  validate(m) {
3855
+ return drain(this.validateSteps(m));
3856
+ }
3857
+ /** `validate` as steps (crew#852): the effective scan's yields (walk + per-file hash); the rules themselves are cheap. */
3858
+ *validateSteps(m) {
3741
3859
  const findings = [];
3742
3860
  // The baseline the records point at must be the bundle its name claims (codex round 7): reset
3743
3861
  // restores from it, `?side=baseline` reads it, the env is provisioned in it — and publish calls
3744
3862
  // this AFTER the provisioner ran, so a provisioner that wrote outside `.venv` is caught here.
3745
- const baselineProblem = this.baselineProblem(m.baseline);
3863
+ const baselineProblem = yield* this.baselineProblemSteps(m.baseline);
3746
3864
  if (baselineProblem !== null)
3747
3865
  findings.push(this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${m.baseline}`, baselineProblem));
3748
- const scan = this.scanEffective();
3866
+ const scan = yield* this.scanEffectiveSteps();
3749
3867
  const scanned = scan.files;
3750
3868
  const onDisk = new Map(scanned.map((f) => [f.rel, f]));
3751
3869
  // Direct filesystem edits: detected by hash, reported, recorded — never silently trusted.
@@ -3790,7 +3908,7 @@ export class SkillsStore {
3790
3908
  }
3791
3909
  // Derived fields are recomputed CONTAINED: a skill whose dir (or a file in it) crosses a symlink
3792
3910
  // is skipped, never read through, and BLOCKS here by name (codex round 5).
3793
- findings.push(...this.recomputeDerived(m));
3911
+ findings.push(...(yield* this.recomputeDerivedSteps(m)));
3794
3912
  // EVERY entry of effective/ is classified (codex round 9): a symlink ANYWHERE under it is refused
3795
3913
  // by name — there is no permitted link there (the store never follows one, and a snapshot would
3796
3914
  // otherwise copy whatever it reaches) — and so is a node that is neither a file nor a directory.
@@ -3829,6 +3947,7 @@ export class SkillsStore {
3829
3947
  const f = onDisk.get(rel);
3830
3948
  if (f !== undefined)
3831
3949
  catalogMd.set(name, readFileNoFollow(f.abs).toString('utf8'));
3950
+ yield;
3832
3951
  }
3833
3952
  // The core closure must be COMPLETE: a registered ref or a mandate naming no catalog skill is
3834
3953
  // a phase the engine would refuse to launch — blocking here, not a warning (v3 §3/§5).