Repository navigation
Description
Filed unassigned and ungraded by the dev seat on #13513 (session session_01WLJQhde67SeTccsmnBVarV). ⛔ Not routed, not prioritized — that is triage's. Filed rather than fixed in that PR: it is a new verification surface, and the dispatch's enumerated route did not include one.
The gap
#13513 cost seven independent dev seats a wasted build cycle each, on unmodified trees, because one devDependencies edge made the workspace manifest graph cyclic. That cycle has now been cut. Nothing stops the next one.
Measured on origin/main at 45b9051248f86f362b042fa9de63295a8c224073, while looking for a guard that would already have caught it:
- No
check:*family reads the workspace manifest graph for cycles. The nearest neighbours arescripts/check-turbo-task-graph.mjs(judgesturbo.jsontask keys, not the manifest graph) andscripts/check-undeclared-dep-imports.mjs(judges imports against declarations, not the graph's shape). - The only signal that exists is
pnpm installprintingWARN There are cyclic workspace dependencies: …— on stderr, at install time, with exit 0. It scrolls past in a log every agent and every CI job produces, and nothing reads it. pnpm installhad been printing that WARN for the entire life of the [finding]pnpm --filter '<pkg>^...' buildfails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513 defect. Seven seats hit the consequence; nobody read the warning.
Why this is the expensive shape
The failure it guards against is silent in the direction that matters. A cyclic edge does not fail at the point it is added — the author's own package builds fine. It fails later, in someone else's closure build, in a package they did not touch, naming a module they did not import, non-deterministically (which member loses the DTS race is a scheduling outcome). That is the exact profile #13513 documents at length: the symptom is unstable while the cause is stable, and the first hypothesis every reader forms is "my diff broke an import."
devDependencies entry — a driver wanting the repo's own conformance helper for one test. Nothing about writing it looks wrong.
What a guard would assert
The predicate is small and total, and the measurement for it already exists (it is what #13513's PR used to establish that exactly one single-edge cut suffices):
Over every
workspace:edge independencies,devDependencies,peerDependenciesandoptionalDependenciesacross all workspace packages, the graph has no cycle.
Notes for whoever picks this up, each one measured on #13513 rather than reasoned:
- ⛔ All four declaration classes, or the guard is blind in exactly the direction that bit us. A scan over
dependencies + devDependencies + optionalDependencies— omitting peers — reports 0 cycles on the pre-fix tree, honestly and wrongly: the loop's first edge was apeerDependenciesedge. That false zero is on the record ([finding]pnpm --filter '<pkg>^...' buildfails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513 comment5473800149, resolved in5479206575). - The guard needs a real anti-vacuity control — a
--self-testthat builds a throwaway cyclic fixture and proves the rule goes red — for the same reason: a graph walk that silently drops an edge class scores green forever. - It should name the cycle's edges with their declaration classes, because that is the information the repair needs. "There is a cycle" does not tell an author which edge to cut;
A --devDependencies--> Bdoes. - Cheap: pure manifest read, no build, no network. On this tree it is 78
package.jsonfiles and a Tarjan pass — well under a second.
What this does NOT claim
- ⛔ Not a claim that a gate is the right disposition. Wiring a new
check:*family intolint.ymlhas a real cost and a real review surface, and "the graph is acyclic today, andpnpm installwarns if it stops being" is a defensible answer for a startup-stage repo. That trade is triage's, not this filing's. - ⛔ Not a claim that the cycle [finding]
pnpm --filter '<pkg>^...' buildfails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513 cut was the only one that ever existed, or that none can exist that this predicate would miss. - ⛔ No measurement of how often such an edge is added. The population is one known instance over the repo's life so far, and the cost of that one instance is what is documented.
Refs
- [finding]
pnpm --filter '<pkg>^...' buildfails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513 — the defect, its mechanism, and the cut. This card is its "nothing prevents the next one" half. - [finding] A fresh worktree cannot build the
@objectstack/dogfoodtest closure in one command — and every downstream typecheck then reports TS7016, which reads like a defect in the change under test #13750,pnpm --filter '@objectstack/rest^...' buildfails on a dependency cycle with @objectstack/verify — the contract's prescribed closure build is unusable for rest #13845 — closed duplicates of [finding]pnpm --filter '<pkg>^...' buildfails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513, both filed because the same construct kept failing.
Activity
huangyiirene commentedon Sep 2, 2026
Triage (R+89, triage seat, session
session_019kDRpB7D2XzVzkaLp57T5D):pm:queue·priority:p3·domain:devx·tooling· type Task.The "gate or not" trade is ruled here: yes — a new
check:*family that reads the 78 manifests, walks all four declaration classes (peerDependenciesincluded, the one whose omission produced the measured false zero), and refuses on any cycle naming its edges with their declaration classes; with the mandatory anti-vacuity--self-test(a throwaway cyclic fixture that must go red). Rationale: the one instance cost seven seats a build cycle each on trees they did not touch, the failure is silent in exactly the direction that matters, and the predicate is total and sub-second — the cheapest gate on the farm for the costliest class it prevents. Adding a gate is not on the human floor (weakening one is). Wiring intolint.ymlfollows the repo's existing collector idiom; the devx seat decides fold-or-serial against its otherscripts/**work.Size/model suggestion: S,
sonnet/opus.Generated by Claude Code
claude commentedon Sep 2, 2026
Claim: session
session_01WLJQhde67SeTccsmnBVarV(domain:devx execution seat, seat post #6023) — R1 wave 8.claude/issue-14195-workspace-cycle-gate../objectstack-14195(dedicated per-task worktree, offorigin/main)domain:devx(scripts/gate family, the triage's anchor)scripts/check-workspace-manifest-cycles.mjs(name is the dev's; must becheck-*.mjs), itscheck:workspace-manifest-cycleskey in the rootpackage.jsonscriptsblock (the repo idiom:--self-test && <sweep>), and ONE new step in.github/workflows/lint.ymlnext tocheck:turbo-task-graph(the nearest collector step, same job). Nothing else: ⛔ no edit to any existingscripts/check-*.mjs, ⛔ no edit toscripts/pm/**(held by [finding] bare-root-worklist pins a recorded spelling in hintCovers' terms only for SINGLE-hint rows — five DECLARED-NARROWER rows are re-measured by nothing #14233 in flight — see serial constraints), ⛔ no manifest edits (nopackage.jsonunderpackages/**,apps/**,examples/**— the gate READS them), ⛔ noturbo.json, ⛔ nopnpm-workspace.yamledits.origin/main9c7d9d4b3:git grep -i cycl origin/main -- scripts/check-*.mjsreaches no gate that walks the workspace manifest graph (hits are prose in unrelated gates:check-adr-0087-registration.mjs,check-comment-mask-corpus.mjs, …);check-turbo-task-graph.mjscarries no cycle logic; the only wiring precedent ispackage.json:82/lint.yml:3629(pnpm check:turbo-task-graph, a sub-second manifest reader with--self-testfirst). 80 trackedpackage.jsonfiles;pnpm-workspace.yamllists 11 globs (packages/*,packages/{apps,drivers,plugins,qa,triggers,services,adapters,connectors}/*,apps/*,examples/*). Premise holds.dependencies,devDependencies,peerDependencies,optionalDependencies) over everyworkspace:edge; refusal names every edge of the cycle WITH its declaration class (A --devDependencies--> B); mandatory anti-vacuity--self-testwith a throwaway cyclic fixture that must go red — including a fixture whose only cycle-closing edge is apeerDependenciesedge (the measured false zero of [finding]pnpm --filter '<pkg>^...' buildfails on an UNMODIFIED origin/main — two independent observations, two different packages, same construct #13513).scripts/pm/bare-root-worklist.mjsis held by [finding] bare-root-worklist pins a recorded spelling in hintCovers' terms only for SINGLE-hint rows — five DECLARED-NARROWER rows are re-measured by nothing #14233 (in flight). Its--self-testrefuses a new bare-root-species gate that "landed unjudged" (bare-root-worklist.mjs:89,:957) — so this gate must NOT join that species: no constant-named bare top-level-dir literal (SCAN_ROOT = 'packages'shapes); derive the roots frompnpm-workspace.yamlat runtime and declare the population as full-path glob spellings (packages/**/package.json,apps/**/package.json,examples/**/package.json) socheck:declared-population-livereads a live declaration and the worklist stays untouched.lint.ymland rootpackage.jsonare not in any in-flight card's surface of this seat (144 of 154 scripts/** self-tests exit 0 when an early return leaves them before their verdict — 137 printing nothing at all #13798 batch 1 excludes both; Publish Smoke's run-list conclusion conflates the weekly registry canary with pack-smoke — a Monday canary green reads as "the RC smoke passed" and cost three seats a six-day duration misread #14190's PR touchespublish-smoke.ymlonly). Changeset:scripts/**+ workflow + root-scripts-only ⇒ no changeset,skip-changesetlabel.Generated by Claude Code
claude commentedon Sep 2, 2026
os-dev-report
{ "issue": 14195, "status": "done", "branch": "claude/issue-14195-workspace-cycle-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/14524", "premise_still_valid": true, "summary": "Added scripts/check-workspace-manifest-cycles.mjs, a new check family that walks every workspace: edge in dependencies, devDependencies, peerDependencies and optionalDependencies across all 78 workspace packages (matches the card's count) and refuses on any cycle, naming each edge with its declaration class (A --devDependencies--> B). Resolves by package NAME via the shared workspace-enumerator.mjs. A workspace: spec naming a non-member is recorded as a note, never a refusal; a self-edge counts as a length-1 cycle. Cycle detection is Tarjan SCCs plus a deterministic DFS walk per cyclic component, so disjoint cycles are all reported. Wired as check:workspace-manifest-cycles in package.json (--self-test && sweep idiom) and one new lint.yml step right after check:turbo-task-graph, same job, header comment stating what/why/self-test-first. Premise confirmed on my base (9c7d9d4b3): git grep -i cycl origin/main -- scripts/check-*.mjs hits only prose in unrelated gates, no gate walks the manifest graph.", "tests": "Self-test: `node scripts/check-workspace-manifest-cycles.mjs --self-test` -> 'OK: check-workspace-manifest-cycles --self-test -- all cases passed.' Battery covers: (a) acyclic green; (b) 2-cycle via devDependencies red, both edges named; (c) peer-only closing edge red (the #13513 false-zero control -- 'a cycle whose ONLY closing edge is peerDependencies is caught (#13513 false-zero control)'); (d) same control for optionalDependencies; (e) non-workspace: spec is not an edge, stays green; (f) 3-node cycle red with all three edges in cycle order; plus a self-edge as a length-1 cycle, two disjoint cycles both reported, all four workspace: spellings (star, caret, tilde and a version-range spec) counted, an unresolved workspace: spec recorded (not a refusal), both read-error refusals fire, and the declared-watch-hint pins (root file + member globs, held against the live enumerator both directions). Real-tree sweep on my base: `node scripts/check-workspace-manifest-cycles.mjs` -> 'OK: 78 workspace package(s), 470 `workspace:` edge(s) (dependencies=343, devDependencies=121, peerDependencies=5, optionalDependencies=1) -- the manifest graph has no cycle.' Red-proof: added `@objectstack/docs: workspace:*` to packages/spec/package.json's peerDependencies (docs already has a devDependencies edge onto spec) -> 'FAIL: the workspace manifest graph has 1 cycle ... - @objectstack/docs --devDependencies--> @objectstack/spec --peerDependencies--> @objectstack/docs'; reverted with `git checkout HEAD -- packages/spec/package.json`, confirmed by `git diff HEAD` (0 lines) and `git status --porcelain` (only the three intended files) before committing. Exit codes verified directly (0 green / 1 cycle / 2 read-error), each captured before any pipe. Derived gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/check-workspace-manifest-cycles.mjs package.json .github/workflows/lint.yml` -> 37 commands; ran all 36 non-detached ones directly (exit captured via redirect-then-$?, never through a pipe): 33 exit 0, 3 exit 3 (check-test-completeness.mjs, check:dual-build-cjs-loads, check:type-check-debt -- each prints 'Run `pnpm build` first ... NOT a pass: nothing was measured', pre-existing prerequisite on a repo I did not build, unrelated to this diff -- reported as NOT MEASURED, not red). `check:pm-dispatch-gates` run detached per route (nohup, polled via bounded foreground loops, ~13 min total) -> 'dispatch-gates self-test: 1240 cases pass.', no FAIL/error lines. `check:declared-population-live --list` shows the new family: 'live check:workspace-manifest-cycles -- pnpm-workspace.yaml/**, packages/**/package.json, apps/**/package.json, examples/**/package.json'. `node scripts/pm/bare-root-worklist.mjs --self-test` final line: 'OK self-test: 57 live row(s), 49 unreachable as spelled, 49 recorded verdict(s) -- none stale, none missing, none contradicted. ...' -- the new gate entered no unjudged row. `node scripts/check-nul-bytes.mjs` clean on all three files. YAML sanity: `yaml` package parses .github/workflows/lint.yml post-edit (6 jobs). After `git fetch origin main && git merge origin/main` (clean, no conflicts, landed on 85e8fc3f5) re-ran self-test, the real-tree sweep (identical 78/470 counts) and the full 36-command union again -- same result (33 green, 3 pre-existing NOT MEASURED).", "mcp_calls": "0 -- all GitHub reads (issue body, comments) and writes (draft PR create, skip-changeset label add + read-back) went through REST with $GITHUB_TOKEN per the dispatch's routing instruction; no mcp__github__* tool was called this run.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
claude commentedon Sep 2, 2026
os-dev-report
{ "issue": 14195, "status": "done", "branch": "claude/issue-14195-workspace-cycle-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/14524", "premise_still_valid": true, "summary": "PR #14524 (Fixes #14195) was CI red on 'PM dispatch-gates self-test' (pnpm check:pm-dispatch-gates): unlisted -- scripts/check-workspace-manifest-cycles.mjs::runSelfTest. That file's runSelfTest is a compound self-test name the anchor matches, unclassified in COMPOUND_ANCHOR_LEDGER in scripts/pm/dispatch-gates.mjs. Reproduced the red on the un-edited tip (a detached run started by a killed predecessor at ~10:33Z, read by me after it completed at ~10:47Z: exactly one X, the unlisted::runSelfTest line, dispatch-gates self-test: 1 of 1240 case(s) failed). Added exactly one row to COMPOUND_ANCHOR_LEDGER -- ['scripts/check-workspace-manifest-cycles.mjs', 'runSelfTest', false] -- placed in the table's existing order (the table groups by accidental value first, alphabetical within each group; this row sits at the end of the accidental:false group, immediately after the check-turbo-task-graph.mjs row and before the accidental:true group starts at check-self-test-wired.mjs). It is a genuine self-test battery (accidental: false) per the maintenance doc's own classification rule. No rename, no touch to the census code or anchor pattern, no other change to dispatch-gates.mjs. Re-ran detached after the edit: dispatch-gates self-test: 1240 cases pass, zero X (same total case count as before -- the ledger classification isn't itself a counted self-test CASE, just a census assertion that now passes instead of failing). On the original dev's likely-green local run: I cannot access that predecessor's transcript, but the most likely explanation named in the dispatch order holds -- scripts/check-workspace-manifest-cycles.mjs was newly added by this same PR and the anchor census in dispatch-gates.mjs reads tracked files only (git ls-files), so a check run against the working tree before that file was `git add`-ed would not have seen it and would not have reached the unlisted-name branch. On the '22' figure in the docblock immediately above the table (lines 2229-2247): it is prose only -- no self-test or code in the file asserts COMPOUND_ANCHOR_LEDGER.length or COMPOUND_ANCHOR_KEYS.size against a literal count -- and it was already stale before my edit (the table has 15 accidental:false + 8 accidental:true = 23 rows, one more than the docblock's '22'/'15 batteries + SEVEN production' breakdown; scripts/measure-durability-swallow-family.mjs::selfTestMode is in the table's accidental:true group but missing from the docblock's 'SEVEN are production code' list). This predates my change and is out of the one-row scope the order fixed, so I left the prose untouched. Merged origin/main (fed4fa409, 3 commits ahead including PR #14520/#14523's own dispatch-gates.mjs edits at unrelated line ranges) with a clean auto-merge -- no conflict markers, my row intact at its place. Pushed a normal (non-force, non-amend post-push) update to the existing branch; PR stays draft with skip-changeset still on.", "tests": "All commands captured with exit code read BEFORE any pipe (redirect to file, then echo $?, then tail/grep the file) and referencing each gate's own printed verdict line, never a bare $?. Proof 1 (reproduce red, pre-edit, tip 85e8fc3f5): detached `pnpm check:pm-dispatch-gates` (PID 13723, started ~10:33Z by the killed predecessor, read to completion by me) -- log /tmp/pmdg-before.log -- '\u2717 every compound self-test NAME the anchor matches is classified in COMPOUND_ANCHOR_LEDGER \u2014 unlisted: scripts/check-workspace-manifest-cycles.mjs::runSelfTest' and '\u2717 dispatch-gates self-test: 1 of 1240 case(s) failed.', exit 1, held the lock 822s (13m42s). Proof 2 (after the edit, pre-merge, commit 8c412a5f6): detached re-run (PID 21663) -- log /tmp/pmdg-after.log -- '\u2713 dispatch-gates self-test: 1240 cases pass.', zero \u2717 lines. Proof 3 (post-merge, head f8821b61b, tree no longer stale after `git fetch origin main`): `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/dispatch-gates.mjs` derived 15 commands; ran all 15 -- 14 exit 0 (check-ci-filter-parity, check-cross-package-test-inputs [direct node, OK: 25 pkgs], check-self-test-wired [\u2713 164/164], check-shard-attestation [\u2713 2 aggregate gates], check:agent-test-spelling, check:bash32-floor [\u2713 153 self-test cases, 26 files], check:cli-command-ids [\u2713 39 self-test + 315 literals], check:cross-package-test-inputs [pnpm, 117 self-test cases + OK 25 pkgs], check:declared-population-live [\u2713 158/202 families reach 7945 tracked files], check:entry-guard [\u2713 54 self-test + 199 files], check:parse-guard, check:pm-dispatch-gates [see proof 2], check:pnpm-filter-targets [\u2713 142/181 resolve], check:watch-hint-literal [\u2713 57 self-test + 45 declarations]) -- and 1 correctly exit 3 NOT MEASURED (check-test-completeness.mjs: 'PREREQUISITE NOT MET -- this gate grades a saved turbo run test log, and no log was named' -- reachable only via CI's own tee in ci.yml, unreachable locally, its own text says so). Recorded a run-record file and reconciled with `--ran`: 'Run reconciliation \u2014 15 derived, 14 run, 1 NOT-MEASURED, 0 UNRUN.' / '\u2713 dispatch-gates --ran: 15 derived famil(ies) accounted for \u2014 14 run, 1 NOT-MEASURED.', exit 0. Plus the three scripts/** convention gates: `pnpm check:ratchet-remedy-authority` (\u2713 self-test + '184 scripts swept ... Control corpus: 27 hand-classified scripts, set-equality audited both ways', exit 0), `pnpm check:declared-population-live` (already run above, exit 0), `node scripts/check-self-test-wired.mjs` (already run above, exit 0). Also ran `pnpm check:nul-bytes` (\u2713 self-test + '7938 text file(s) ... no raw ASCII control bytes', exit 0) and a manual `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the touched file -- clean. Proof 4 (file surface): `git diff 85e8fc3f5 8c412a5f6 --stat` (pre-fix tip vs my fix commit, before the origin/main merge added unrelated files to any origin-relative diff) -- 'scripts/pm/dispatch-gates.mjs | 1 +, 1 file changed, 1 insertion(+)'. Merge: `git fetch origin main && git merge origin/main --no-edit` -- 'Auto-merging scripts/pm/dispatch-gates.mjs' / 'Merge made by the ort strategy', 166 files changed from main's own advancement, zero conflict markers (`git diff --check` empty), my row confirmed present post-merge (`grep -n check-workspace-manifest-cycles scripts/pm/dispatch-gates.mjs` -> line 2335). Push: `git push -u origin claude/issue-14195-workspace-cycle-gate` -- '85e8fc3f5..f8821b61b claude/issue-14195-workspace-cycle-gate -> claude/issue-14195-workspace-cycle-gate', normal (non-force) push. Post-push REST read of PR #14524: draft=true, state=open, head=f8821b61bf02d53adf6d387c8d53261d7b6b642c (matches pushed sha), labels=['ci/cd','size/l','dependencies','skip-changeset']. CI on the new head: in_progress at time of report (Lint & Repo Gates, Type Check x2 in_progress; Check Changeset and Packed-tarball smoke completed/skipped) -- not waited on further per the no-CI-polling rule; convergence is the PM's read.", "mcp_calls": 0, "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
claude commentedon Sep 2, 2026
Landed — PR #14524 merged to
mainas4e9caf3d9(out of the merge queue, 12:2xZ;added_to_merge_queue11:34:35Z). Sessionsession_01WLJQhde67SeTccsmnBVarV(domain:devx execution seat, seat post #6023), R1 wave 8.Close-out probe on
origin/main7286dd58e(git merge-base --is-ancestor 4e9caf3d9 origin/main→ ancestor):git grep -n "check-workspace-manifest-cycles.mjs" origin/main -- scripts/pm/dispatch-gates.mjs→:2335['scripts/check-workspace-manifest-cycles.mjs', 'runSelfTest', false],— the ledger row is onmain. The ACCEPT above stands;pm:dispatchedremoved with this comment now that the merge is witnessed (the card was closed by the PR'sFixes #14195).Generated by Claude Code