@cleocode/core 2026.10.1 → 2026.10.2
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/build-provenance.json +2 -2
- package/dist/cloud/journal.d.ts +9 -5
- package/dist/cloud/journal.d.ts.map +1 -1
- package/dist/cloud/journal.js +29 -8
- package/dist/cloud/journal.js.map +1 -1
- package/dist/cloud/manifest-check.d.ts +140 -4
- package/dist/cloud/manifest-check.d.ts.map +1 -1
- package/dist/cloud/manifest-check.js +309 -10
- package/dist/cloud/manifest-check.js.map +1 -1
- package/dist/cloud/nexus-attach.d.ts +25 -0
- package/dist/cloud/nexus-attach.d.ts.map +1 -1
- package/dist/cloud/nexus-attach.js +81 -6
- package/dist/cloud/nexus-attach.js.map +1 -1
- package/dist/cloud/nexus-cloud-activity.d.ts +37 -0
- package/dist/cloud/nexus-cloud-activity.d.ts.map +1 -0
- package/dist/cloud/nexus-cloud-activity.js +79 -0
- package/dist/cloud/nexus-cloud-activity.js.map +1 -0
- package/dist/cloud/nexus-cloud-status.d.ts.map +1 -1
- package/dist/cloud/nexus-cloud-status.js +36 -0
- package/dist/cloud/nexus-cloud-status.js.map +1 -1
- package/dist/cloud/nexus-device.d.ts +1 -1
- package/dist/cloud/nexus-enrol.d.ts.map +1 -1
- package/dist/cloud/nexus-enrol.js +5 -0
- package/dist/cloud/nexus-enrol.js.map +1 -1
- package/dist/cloud/nexus-home.d.ts +74 -0
- package/dist/cloud/nexus-home.d.ts.map +1 -0
- package/dist/cloud/nexus-home.js +97 -0
- package/dist/cloud/nexus-home.js.map +1 -0
- package/dist/cloud/nexus-vault-keys.d.ts +129 -0
- package/dist/cloud/nexus-vault-keys.d.ts.map +1 -0
- package/dist/cloud/nexus-vault-keys.js +277 -0
- package/dist/cloud/nexus-vault-keys.js.map +1 -0
- package/dist/cloud/nexus-vault-state.d.ts +121 -0
- package/dist/cloud/nexus-vault-state.d.ts.map +1 -0
- package/dist/cloud/nexus-vault-state.js +274 -0
- package/dist/cloud/nexus-vault-state.js.map +1 -0
- package/dist/cloud/nexus-vault.d.ts +160 -0
- package/dist/cloud/nexus-vault.d.ts.map +1 -0
- package/dist/cloud/nexus-vault.js +1733 -0
- package/dist/cloud/nexus-vault.js.map +1 -0
- package/dist/cloud/signing.d.ts +24 -8
- package/dist/cloud/signing.d.ts.map +1 -1
- package/dist/cloud/signing.js +61 -15
- package/dist/cloud/signing.js.map +1 -1
- package/dist/docs/export-document.js +8137 -3318
- package/dist/docs/export-document.js.map +3 -3
- package/dist/doctor/migrations.d.ts +75 -0
- package/dist/doctor/migrations.d.ts.map +1 -0
- package/dist/doctor/migrations.js +136 -0
- package/dist/doctor/migrations.js.map +1 -0
- package/dist/doctor/sync-sealer.d.ts +48 -0
- package/dist/doctor/sync-sealer.d.ts.map +1 -0
- package/dist/doctor/sync-sealer.js +109 -0
- package/dist/doctor/sync-sealer.js.map +1 -0
- package/dist/doctor/sync-triggers.d.ts +53 -0
- package/dist/doctor/sync-triggers.d.ts.map +1 -0
- package/dist/doctor/sync-triggers.js +157 -0
- package/dist/doctor/sync-triggers.js.map +1 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +5 -1
- package/dist/init.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/llm/plugin-facade.js +8442 -3623
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/nexus/projects-clean.d.ts +3 -0
- package/dist/nexus/projects-clean.d.ts.map +1 -1
- package/dist/nexus/projects-clean.js +9 -3
- package/dist/nexus/projects-clean.js.map +1 -1
- package/dist/nexus/registry-roots.d.ts +3 -1
- package/dist/nexus/registry-roots.d.ts.map +1 -1
- package/dist/nexus/registry-roots.js +5 -2
- package/dist/nexus/registry-roots.js.map +1 -1
- package/dist/nexus/registry.d.ts +8 -1
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +24 -10
- package/dist/nexus/registry.js.map +1 -1
- package/dist/nexus/workspace.d.ts +4 -1
- package/dist/nexus/workspace.d.ts.map +1 -1
- package/dist/nexus/workspace.js +10 -4
- package/dist/nexus/workspace.js.map +1 -1
- package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
- package/dist/orchestrate/spawn-ops.js +7 -2
- package/dist/orchestrate/spawn-ops.js.map +1 -1
- package/dist/orchestrate/worker-verify.d.ts +121 -12
- package/dist/orchestrate/worker-verify.d.ts.map +1 -1
- package/dist/orchestrate/worker-verify.js +172 -21
- package/dist/orchestrate/worker-verify.js.map +1 -1
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +15 -9
- package/dist/orchestration/spawn-prompt.js.map +1 -1
- package/dist/release/ci-evidence.d.ts +9 -0
- package/dist/release/ci-evidence.d.ts.map +1 -1
- package/dist/release/ci-evidence.js +9 -2
- package/dist/release/ci-evidence.js.map +1 -1
- package/dist/resources/backend.d.ts +15 -3
- package/dist/resources/backend.d.ts.map +1 -1
- package/dist/resources/backend.js +7 -3
- package/dist/resources/backend.js.map +1 -1
- package/dist/resources/darwin-backend.d.ts +142 -0
- package/dist/resources/darwin-backend.d.ts.map +1 -0
- package/dist/resources/darwin-backend.js +288 -0
- package/dist/resources/darwin-backend.js.map +1 -0
- package/dist/resources/governor.d.ts +44 -1
- package/dist/resources/governor.d.ts.map +1 -1
- package/dist/resources/governor.js +100 -36
- package/dist/resources/governor.js.map +1 -1
- package/dist/resources/heavy-command.d.ts +132 -0
- package/dist/resources/heavy-command.d.ts.map +1 -0
- package/dist/resources/heavy-command.js +768 -0
- package/dist/resources/heavy-command.js.map +1 -0
- package/dist/resources/monitor.d.ts +50 -1
- package/dist/resources/monitor.d.ts.map +1 -1
- package/dist/resources/monitor.js +83 -1
- package/dist/resources/monitor.js.map +1 -1
- package/dist/resources/run-admission.d.ts +303 -0
- package/dist/resources/run-admission.d.ts.map +1 -0
- package/dist/resources/run-admission.js +648 -0
- package/dist/resources/run-admission.js.map +1 -0
- package/dist/resources/run-class.d.ts +111 -0
- package/dist/resources/run-class.d.ts.map +1 -0
- package/dist/resources/run-class.js +658 -0
- package/dist/resources/run-class.js.map +1 -0
- package/dist/resources/run-governed.d.ts +158 -0
- package/dist/resources/run-governed.d.ts.map +1 -0
- package/dist/resources/run-governed.js +406 -0
- package/dist/resources/run-governed.js.map +1 -0
- package/dist/resources/slot-holder.d.ts +267 -0
- package/dist/resources/slot-holder.d.ts.map +1 -0
- package/dist/resources/slot-holder.js +505 -0
- package/dist/resources/slot-holder.js.map +1 -0
- package/dist/resources/tool-groups.d.ts +74 -0
- package/dist/resources/tool-groups.d.ts.map +1 -0
- package/dist/resources/tool-groups.js +118 -0
- package/dist/resources/tool-groups.js.map +1 -0
- package/dist/routing/build-command-groups.d.ts.map +1 -1
- package/dist/routing/build-command-groups.js +1 -0
- package/dist/routing/build-command-groups.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +8446 -3627
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/sentient/cross-project-hygiene.d.ts +3 -2
- package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
- package/dist/sentient/cross-project-hygiene.js +5 -2
- package/dist/sentient/cross-project-hygiene.js.map +1 -1
- package/dist/sentient/state.d.ts +7 -0
- package/dist/sentient/state.d.ts.map +1 -1
- package/dist/sentient/state.js.map +1 -1
- package/dist/sentient/tick.d.ts +14 -2
- package/dist/sentient/tick.d.ts.map +1 -1
- package/dist/sentient/tick.js +63 -4
- package/dist/sentient/tick.js.map +1 -1
- package/dist/store/backup-recover.d.ts.map +1 -1
- package/dist/store/backup-recover.js +5 -0
- package/dist/store/backup-recover.js.map +1 -1
- package/dist/store/data-accessor.d.ts +1 -0
- package/dist/store/data-accessor.d.ts.map +1 -1
- package/dist/store/data-accessor.js +7 -0
- package/dist/store/data-accessor.js.map +1 -1
- package/dist/store/dual-scope-db.d.ts +23 -1
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +126 -36
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/exodus/recovery.d.ts.map +1 -1
- package/dist/store/exodus/recovery.js +15 -0
- package/dist/store/exodus/recovery.js.map +1 -1
- package/dist/store/legacy-tasks-lineage.d.ts.map +1 -1
- package/dist/store/legacy-tasks-lineage.js +5 -0
- package/dist/store/legacy-tasks-lineage.js.map +1 -1
- package/dist/store/lock.d.ts +8 -1
- package/dist/store/lock.d.ts.map +1 -1
- package/dist/store/lock.js +10 -2
- package/dist/store/lock.js.map +1 -1
- package/dist/store/migration-manager.d.ts +5 -0
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/migration-manager.js +11 -0
- package/dist/store/migration-manager.js.map +1 -1
- package/dist/store/migration-runner.d.ts +158 -0
- package/dist/store/migration-runner.d.ts.map +1 -0
- package/dist/store/migration-runner.js +493 -0
- package/dist/store/migration-runner.js.map +1 -0
- package/dist/store/portable-bundle-import.d.ts +15 -1
- package/dist/store/portable-bundle-import.d.ts.map +1 -1
- package/dist/store/portable-bundle-import.js +24 -4
- package/dist/store/portable-bundle-import.js.map +1 -1
- package/dist/store/portable-bundle-relocate.d.ts +43 -0
- package/dist/store/portable-bundle-relocate.d.ts.map +1 -1
- package/dist/store/portable-bundle-relocate.js +79 -5
- package/dist/store/portable-bundle-relocate.js.map +1 -1
- package/dist/store/portable-bundle-scan.d.ts +20 -0
- package/dist/store/portable-bundle-scan.d.ts.map +1 -1
- package/dist/store/portable-bundle-scan.js +38 -3
- package/dist/store/portable-bundle-scan.js.map +1 -1
- package/dist/store/portable-bundle.d.ts +33 -0
- package/dist/store/portable-bundle.d.ts.map +1 -1
- package/dist/store/portable-bundle.js +34 -7
- package/dist/store/portable-bundle.js.map +1 -1
- package/dist/store/row-identity-registry.d.ts +137 -7
- package/dist/store/row-identity-registry.d.ts.map +1 -1
- package/dist/store/row-identity-registry.js +364 -9
- package/dist/store/row-identity-registry.js.map +1 -1
- package/dist/store/row-identity.d.ts.map +1 -1
- package/dist/store/row-identity.js +9 -10
- package/dist/store/row-identity.js.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.js +10 -0
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/sqlite.d.ts +31 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +5 -1
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sync/capture.d.ts +203 -0
- package/dist/store/sync/capture.d.ts.map +1 -0
- package/dist/store/sync/capture.js +562 -0
- package/dist/store/sync/capture.js.map +1 -0
- package/dist/store/sync/clock-store.d.ts.map +1 -1
- package/dist/store/sync/clock-store.js +2 -0
- package/dist/store/sync/clock-store.js.map +1 -1
- package/dist/store/sync/fk-orphans.d.ts +133 -0
- package/dist/store/sync/fk-orphans.d.ts.map +1 -0
- package/dist/store/sync/fk-orphans.js +265 -0
- package/dist/store/sync/fk-orphans.js.map +1 -0
- package/dist/store/sync/flags.d.ts +7 -0
- package/dist/store/sync/flags.d.ts.map +1 -1
- package/dist/store/sync/flags.js +16 -0
- package/dist/store/sync/flags.js.map +1 -1
- package/dist/store/sync/hlc.d.ts.map +1 -1
- package/dist/store/sync/hlc.js +4 -0
- package/dist/store/sync/hlc.js.map +1 -1
- package/dist/store/sync/identity-fill.d.ts +45 -0
- package/dist/store/sync/identity-fill.d.ts.map +1 -0
- package/dist/store/sync/identity-fill.js +85 -0
- package/dist/store/sync/identity-fill.js.map +1 -0
- package/dist/store/sync/machinery.d.ts +27 -0
- package/dist/store/sync/machinery.d.ts.map +1 -0
- package/dist/store/sync/machinery.js +44 -0
- package/dist/store/sync/machinery.js.map +1 -0
- package/dist/store/sync/netting.d.ts +120 -0
- package/dist/store/sync/netting.d.ts.map +1 -0
- package/dist/store/sync/netting.js +336 -0
- package/dist/store/sync/netting.js.map +1 -0
- package/dist/store/sync/replica.d.ts +27 -2
- package/dist/store/sync/replica.d.ts.map +1 -1
- package/dist/store/sync/replica.js +47 -3
- package/dist/store/sync/replica.js.map +1 -1
- package/dist/store/sync/schema.d.ts +12 -0
- package/dist/store/sync/schema.d.ts.map +1 -1
- package/dist/store/sync/schema.js +21 -0
- package/dist/store/sync/schema.js.map +1 -1
- package/dist/store/sync/seal-backlog.d.ts +21 -0
- package/dist/store/sync/seal-backlog.d.ts.map +1 -0
- package/dist/store/sync/seal-backlog.js +24 -0
- package/dist/store/sync/seal-backlog.js.map +1 -0
- package/dist/store/sync/sealer-values.d.ts +34 -0
- package/dist/store/sync/sealer-values.d.ts.map +1 -0
- package/dist/store/sync/sealer-values.js +53 -0
- package/dist/store/sync/sealer-values.js.map +1 -0
- package/dist/store/sync/sealer.d.ts +145 -0
- package/dist/store/sync/sealer.d.ts.map +1 -0
- package/dist/store/sync/sealer.js +906 -0
- package/dist/store/sync/sealer.js.map +1 -0
- package/dist/store/sync/structural.d.ts +63 -0
- package/dist/store/sync/structural.d.ts.map +1 -0
- package/dist/store/sync/structural.js +180 -0
- package/dist/store/sync/structural.js.map +1 -0
- package/dist/store/sync/timestamps.d.ts +92 -0
- package/dist/store/sync/timestamps.d.ts.map +1 -0
- package/dist/store/sync/timestamps.js +228 -0
- package/dist/store/sync/timestamps.js.map +1 -0
- package/dist/store/sync/tombstones.d.ts +75 -0
- package/dist/store/sync/tombstones.d.ts.map +1 -0
- package/dist/store/sync/tombstones.js +103 -0
- package/dist/store/sync/tombstones.js.map +1 -0
- package/dist/store/sync/trigger-classes.d.ts +221 -0
- package/dist/store/sync/trigger-classes.d.ts.map +1 -0
- package/dist/store/sync/trigger-classes.js +441 -0
- package/dist/store/sync/trigger-classes.js.map +1 -0
- package/dist/store/sync/trigger-suspend-at-path.d.ts +23 -0
- package/dist/store/sync/trigger-suspend-at-path.d.ts.map +1 -0
- package/dist/store/sync/trigger-suspend-at-path.js +44 -0
- package/dist/store/sync/trigger-suspend-at-path.js.map +1 -0
- package/dist/store/sync/writer-version.d.ts +43 -0
- package/dist/store/sync/writer-version.d.ts.map +1 -0
- package/dist/store/sync/writer-version.js +90 -0
- package/dist/store/sync/writer-version.js.map +1 -0
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +102 -0
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/trigger-suspend-schema.d.ts +44 -0
- package/dist/store/trigger-suspend-schema.d.ts.map +1 -0
- package/dist/store/trigger-suspend-schema.js +25 -0
- package/dist/store/trigger-suspend-schema.js.map +1 -0
- package/dist/store/vault-manifest.d.ts +365 -0
- package/dist/store/vault-manifest.d.ts.map +1 -0
- package/dist/store/vault-manifest.js +812 -0
- package/dist/store/vault-manifest.js.map +1 -0
- package/dist/store/writer-lease.d.ts +31 -0
- package/dist/store/writer-lease.d.ts.map +1 -1
- package/dist/store/writer-lease.js +65 -2
- package/dist/store/writer-lease.js.map +1 -1
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +21 -0
- package/dist/system/health.js.map +1 -1
- package/dist/system/project-health.d.ts.map +1 -1
- package/dist/system/project-health.js +3 -1
- package/dist/system/project-health.js.map +1 -1
- package/dist/tasks/affected-packages.d.ts +58 -0
- package/dist/tasks/affected-packages.d.ts.map +1 -1
- package/dist/tasks/affected-packages.js +75 -6
- package/dist/tasks/affected-packages.js.map +1 -1
- package/dist/tasks/affected-scope.d.ts +150 -23
- package/dist/tasks/affected-scope.d.ts.map +1 -1
- package/dist/tasks/affected-scope.js +320 -32
- package/dist/tasks/affected-scope.js.map +1 -1
- package/dist/tasks/change-set.d.ts +96 -0
- package/dist/tasks/change-set.d.ts.map +1 -1
- package/dist/tasks/change-set.js +169 -4
- package/dist/tasks/change-set.js.map +1 -1
- package/dist/tasks/complete-ci.d.ts +115 -0
- package/dist/tasks/complete-ci.d.ts.map +1 -0
- package/dist/tasks/complete-ci.js +171 -0
- package/dist/tasks/complete-ci.js.map +1 -0
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +50 -17
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/compute-task-view.d.ts.map +1 -1
- package/dist/tasks/compute-task-view.js +7 -0
- package/dist/tasks/compute-task-view.js.map +1 -1
- package/dist/tasks/done-plan.d.ts.map +1 -1
- package/dist/tasks/done-plan.js +51 -30
- package/dist/tasks/done-plan.js.map +1 -1
- package/dist/tasks/done-record.d.ts +6 -0
- package/dist/tasks/done-record.d.ts.map +1 -1
- package/dist/tasks/done-record.js +9 -3
- package/dist/tasks/done-record.js.map +1 -1
- package/dist/tasks/evidence.d.ts +3 -2
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +143 -12
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/gate-notes.d.ts +22 -0
- package/dist/tasks/gate-notes.d.ts.map +1 -0
- package/dist/tasks/gate-notes.js +33 -0
- package/dist/tasks/gate-notes.js.map +1 -0
- package/dist/tasks/index.d.ts +2 -1
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js +2 -1
- package/dist/tasks/index.js.map +1 -1
- package/dist/tasks/test-run-binding.d.ts +117 -0
- package/dist/tasks/test-run-binding.d.ts.map +1 -0
- package/dist/tasks/test-run-binding.js +365 -0
- package/dist/tasks/test-run-binding.js.map +1 -0
- package/dist/tasks/tool-cache-env.d.ts +123 -0
- package/dist/tasks/tool-cache-env.d.ts.map +1 -0
- package/dist/tasks/tool-cache-env.js +287 -0
- package/dist/tasks/tool-cache-env.js.map +1 -0
- package/dist/tasks/tool-cache-failed-first.d.ts +155 -0
- package/dist/tasks/tool-cache-failed-first.d.ts.map +1 -0
- package/dist/tasks/tool-cache-failed-first.js +285 -0
- package/dist/tasks/tool-cache-failed-first.js.map +1 -0
- package/dist/tasks/tool-cache.d.ts +375 -55
- package/dist/tasks/tool-cache.d.ts.map +1 -1
- package/dist/tasks/tool-cache.js +912 -303
- package/dist/tasks/tool-cache.js.map +1 -1
- package/dist/tasks/tool-resolver.d.ts +4 -3
- package/dist/tasks/tool-resolver.d.ts.map +1 -1
- package/dist/tasks/tool-resolver.js +15 -4
- package/dist/tasks/tool-resolver.js.map +1 -1
- package/dist/tasks/tool-semaphore.d.ts +67 -7
- package/dist/tasks/tool-semaphore.d.ts.map +1 -1
- package/dist/tasks/tool-semaphore.js +134 -56
- package/dist/tasks/tool-semaphore.js.map +1 -1
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +4 -0
- package/dist/upgrade.js.map +1 -1
- package/dist/validation/engine-ops.d.ts +6 -0
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +8 -0
- package/dist/validation/engine-ops.js.map +1 -1
- package/migrations/drizzle-cleo-project/20260930170000_t12819-trigger-suspend-clause/migration.sql +367 -0
- package/migrations/sync-journal/20260930120000_t12343-capture/migration.sql +47 -0
- package/migrations/sync-journal/20261001170000_t12984-sealer/migration.sql +101 -0
- package/package.json +13 -13
- package/templates/CLEO-INJECTION.md +5 -5
- package/templates/CLEO-REFERENCE.md +8 -4
package/dist/tasks/tool-cache.js
CHANGED
|
@@ -8,15 +8,27 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This module wraps tool execution with:
|
|
10
10
|
*
|
|
11
|
-
* 1. **Content-addressed cache** — keyed on `(canonical, cmd, args,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 1. **Content-addressed cache** — keyed on `(canonical, cmd, args,
|
|
12
|
+
* treeHash)`, where `treeHash` is the git tree of the tracked content as
|
|
13
|
+
* it sits in the working tree (T12958, see {@link captureTreeHash}).
|
|
14
|
+
* Cache hits return the prior `exitCode + stdoutTail` without spawning
|
|
15
|
+
* the tool. Two worktrees holding the same content, or a commit, rebase
|
|
16
|
+
* or amend that leaves the tree unchanged, share one result.
|
|
14
17
|
* 2. **Cross-process semaphore** — when N processes simultaneously miss the
|
|
15
18
|
* cache, only one runs the tool; the rest block on a `proper-lockfile`
|
|
16
19
|
* and read the freshly-written cache entry.
|
|
17
|
-
* 3. **Automatic invalidation** — entries become
|
|
18
|
-
* changes
|
|
19
|
-
*
|
|
20
|
+
* 3. **Automatic invalidation** — entries become unreachable as soon as the
|
|
21
|
+
* tracked content changes, because the key moves with it. Nothing is
|
|
22
|
+
* deleted on access (no GC daemon required).
|
|
23
|
+
* 4. **Failed-first reruns** — a failing `test` run remembers its failing
|
|
24
|
+
* test files; the next run re-runs those first and stops if they still
|
|
25
|
+
* fail (T12961, see `tool-cache-failed-first.ts`). A failing run is
|
|
26
|
+
* re-run in full once; a pass then is recorded as `flaky`.
|
|
27
|
+
* 5. **Resource kills are not results** — a run killed by a signal, an
|
|
28
|
+
* exit of 128 + a kill signal, or a heap OOM is returned but never
|
|
29
|
+
* cached, and the key includes the heap and worker limits the run was
|
|
30
|
+
* given, so a retry with more memory always runs (T12989, see
|
|
31
|
+
* {@link resourceKillReason}).
|
|
20
32
|
*
|
|
21
33
|
* Cache layout (under `<projectRoot>/.cleo/cache/evidence/`):
|
|
22
34
|
*
|
|
@@ -31,17 +43,29 @@
|
|
|
31
43
|
*/
|
|
32
44
|
import { execFileSync, spawn } from 'node:child_process';
|
|
33
45
|
import { createHash } from 'node:crypto';
|
|
34
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
46
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, } from 'node:fs';
|
|
47
|
+
import { constants as osConstants, tmpdir } from 'node:os';
|
|
35
48
|
import { join, resolve } from 'node:path';
|
|
36
49
|
import { ExitCode } from '@cleocode/contracts';
|
|
37
50
|
import { CleoError } from '../errors.js';
|
|
38
|
-
import {
|
|
51
|
+
import { activeToolGroups, trackToolGroup } from '../resources/tool-groups.js';
|
|
52
|
+
import { isLocked, withLock } from '../store/lock.js';
|
|
39
53
|
import { heavyToolEnv } from './heavy-tool-env.js';
|
|
40
54
|
import { confinementStartupFailure, isSystemdRunCommand, withMemoryLimit, } from './heavy-tool-limit.js';
|
|
55
|
+
import { captureEnvFingerprint, captureResourceEnv } from './tool-cache-env.js';
|
|
56
|
+
import { FAILED_FIRST_TOOLS, parseFailingTestFiles, planFocusedRuns, readFailedFirstPointer, reportsNoTestFiles, writeFailedFirstPointer, } from './tool-cache-failed-first.js';
|
|
41
57
|
import { acquireGlobalSlot } from './tool-semaphore.js';
|
|
42
58
|
// ---------------------------------------------------------------------------
|
|
43
59
|
// Wall-clock deadline resolution (T12105 / gh#1193)
|
|
44
60
|
// ---------------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* Most failing test files a failed `test` run may name and still get its one
|
|
63
|
+
* full flake retry (T12961). More, or none named, is treated as a
|
|
64
|
+
* deterministic failure: no retry.
|
|
65
|
+
*
|
|
66
|
+
* @task T12961
|
|
67
|
+
*/
|
|
68
|
+
export const MAX_FLAKE_RETRY_FILES = 3;
|
|
45
69
|
/**
|
|
46
70
|
* Default wall-clock deadline for one evidence-tool child process, in ms.
|
|
47
71
|
*
|
|
@@ -200,27 +224,45 @@ export function resolveSpawnTimeoutMs(canonical, env = process.env) {
|
|
|
200
224
|
* {@link isEntryUsable} (gh#1380: an unknown outcome must never be cached).
|
|
201
225
|
* Putting it here would key the cache on its own answer.
|
|
202
226
|
*
|
|
227
|
+
* `head` and `executionRoot` are recorded on every entry but are no longer
|
|
228
|
+
* identity (T12958). Both name a LOCATION — which commit, which directory —
|
|
229
|
+
* rather than the content measured, so keying on them made every worktree and
|
|
230
|
+
* every commit a miss even when the tracked content was byte-identical.
|
|
231
|
+
* `treeHash` is the content itself; it subsumes the old `dirtyFingerprint`.
|
|
232
|
+
* `envFingerprint` covers the per-checkout state git does not see (installed
|
|
233
|
+
* deps, gitignored build output, `.env*`), so sharing across worktrees
|
|
234
|
+
* happens only when BOTH the source and the environment match.
|
|
235
|
+
* `resourceEnv` (T12989) covers the limits the run was spawned with — heap
|
|
236
|
+
* flags and worker counts — which neither the tree nor the checkout shows: a
|
|
237
|
+
* 3 GB run that ran out of memory and a 6 GB retry of the same code are two
|
|
238
|
+
* runs, and keying them as one is what replayed the OOM.
|
|
239
|
+
*
|
|
203
240
|
* @task T12190 (gh#1419)
|
|
241
|
+
* @task T12958
|
|
242
|
+
* @task T12989
|
|
204
243
|
*/
|
|
205
244
|
export const TOOL_RUN_IDENTITY_FIELDS = [
|
|
206
245
|
'canonical',
|
|
207
246
|
'cmd',
|
|
208
247
|
'args',
|
|
209
|
-
'
|
|
210
|
-
'
|
|
211
|
-
'
|
|
248
|
+
'treeHash',
|
|
249
|
+
'envFingerprint',
|
|
250
|
+
'resourceEnv',
|
|
212
251
|
];
|
|
252
|
+
/**
|
|
253
|
+
* Current {@link ToolCacheEntry.schemaVersion}. Entries of any other version
|
|
254
|
+
* are refused on read.
|
|
255
|
+
*
|
|
256
|
+
* @task T12958
|
|
257
|
+
* @task T12989
|
|
258
|
+
*/
|
|
259
|
+
export const TOOL_CACHE_SCHEMA_VERSION = 4;
|
|
213
260
|
/**
|
|
214
261
|
* Normalise an execution root to a stable, comparable absolute path.
|
|
215
262
|
*
|
|
216
|
-
* Symlinks are resolved so
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* split one tree's cache across spellings. A path that cannot be resolved
|
|
220
|
-
* (it has been deleted) falls back to lexical resolution rather than
|
|
221
|
-
* throwing: callers of {@link computeCacheKey} must be able to compute the
|
|
222
|
-
* key of a run whose directory is already gone, precisely so
|
|
223
|
-
* {@link readCacheEntry} can then refuse it.
|
|
263
|
+
* Symlinks are resolved so two spellings of one directory compare equal. A
|
|
264
|
+
* path that cannot be resolved (it has been deleted) falls back to lexical
|
|
265
|
+
* resolution rather than throwing.
|
|
224
266
|
*
|
|
225
267
|
* @task T12190 (gh#1419)
|
|
226
268
|
*/
|
|
@@ -233,34 +275,40 @@ function normalizeExecutionRoot(executionRoot) {
|
|
|
233
275
|
}
|
|
234
276
|
}
|
|
235
277
|
/**
|
|
236
|
-
* Compute the cache key for a resolved tool command +
|
|
278
|
+
* Compute the cache key for a resolved tool command + tracked tree content.
|
|
237
279
|
*
|
|
238
280
|
* The key covers exactly {@link TOOL_RUN_IDENTITY_FIELDS} — canonical tool
|
|
239
|
-
* name, resolved command and args,
|
|
240
|
-
*
|
|
241
|
-
* declares, so the hashed payload is a function of the
|
|
242
|
-
* field there changes every key by construction.
|
|
281
|
+
* name, resolved command and args, the tree hash, the environment
|
|
282
|
+
* fingerprint and the resource environment. Fields are projected in
|
|
283
|
+
* the order that array declares, so the hashed payload is a function of the
|
|
284
|
+
* array and adding a field there changes every key by construction.
|
|
243
285
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* shape of the defect this fixes, since `executionRoot` already existed and
|
|
248
|
-
* already reached this function's caller.
|
|
286
|
+
* There is deliberately no directory parameter (T12958): two checkouts with
|
|
287
|
+
* the same tracked content get the same key, which is what lets parallel
|
|
288
|
+
* worktrees and post-rebase re-verifies reuse one run.
|
|
249
289
|
*
|
|
250
290
|
* Using `createHash('sha256')` makes the key collision-resistant and bounded
|
|
251
291
|
* to 32 hex chars regardless of input size.
|
|
252
292
|
*
|
|
293
|
+
* @param command - Resolved tool command.
|
|
294
|
+
* @param treeHash - {@link captureTreeHash} of the execution root.
|
|
295
|
+
* @param envFingerprint - {@link captureEnvFingerprint} of the execution root.
|
|
296
|
+
* @param resourceEnv - {@link captureResourceEnv} of the spawn environment.
|
|
297
|
+
* @returns 32 hex chars.
|
|
298
|
+
*
|
|
253
299
|
* @task T1534
|
|
254
300
|
* @task T12190 (gh#1419)
|
|
301
|
+
* @task T12958
|
|
302
|
+
* @task T12989
|
|
255
303
|
*/
|
|
256
|
-
export function computeCacheKey(command,
|
|
304
|
+
export function computeCacheKey(command, treeHash, envFingerprint, resourceEnv) {
|
|
257
305
|
const identity = {
|
|
258
306
|
canonical: command.canonical,
|
|
259
307
|
cmd: command.cmd,
|
|
260
308
|
args: command.args,
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
309
|
+
treeHash,
|
|
310
|
+
envFingerprint,
|
|
311
|
+
resourceEnv,
|
|
264
312
|
};
|
|
265
313
|
// Projected through TOOL_RUN_IDENTITY_FIELDS rather than written as an
|
|
266
314
|
// object literal: the literal is what drifted from the entry shape before.
|
|
@@ -271,18 +319,22 @@ export function computeCacheKey(command, head, dirtyFingerprint, executionRoot)
|
|
|
271
319
|
* Whether a cache entry may be SERVED or PERSISTED as a real result.
|
|
272
320
|
*
|
|
273
321
|
* One predicate, used by both {@link readCacheEntry} and the persist guard in
|
|
274
|
-
* `runToolCached`, so the two cannot drift apart.
|
|
322
|
+
* `runToolCached`, so the two cannot drift apart. Three rules:
|
|
275
323
|
*
|
|
276
324
|
* 1. every field in {@link TOOL_RUN_IDENTITY_FIELDS} is present and
|
|
277
325
|
* non-null — an entry that cannot say which run it describes is not
|
|
278
326
|
* evidence of anything;
|
|
279
327
|
* 2. `exitCode` is non-null — a run that produced no exit code records that
|
|
280
328
|
* we do not know what happened, and an unknown must never be cached
|
|
281
|
-
* (gh#1380)
|
|
329
|
+
* (gh#1380);
|
|
330
|
+
* 3. `resourceKill` is absent — a run killed for resources (an OOM, a heap
|
|
331
|
+
* limit, exit 137) has a real exit code that says nothing about the code,
|
|
332
|
+
* and caching it replays the kill to every retry, including one given
|
|
333
|
+
* the memory it lacked (T12989).
|
|
282
334
|
*
|
|
283
335
|
* Rule 1 subsumes gh#1404's `head === null` clause without naming `head`:
|
|
284
|
-
* when the tool runs off a non-git root
|
|
285
|
-
*
|
|
336
|
+
* when the tool runs off a non-git root `treeHash` is null on EVERY run, so
|
|
337
|
+
* the key is a function of the command alone and nothing a developer
|
|
286
338
|
* does to the source can rotate it. That is not a cache, it is a hardcoded
|
|
287
339
|
* answer with a filename — and unlike gh#1380 it holds a real exit code,
|
|
288
340
|
* which can be a PASS. A poisoned red is investigated within minutes; nobody
|
|
@@ -293,6 +345,7 @@ export function computeCacheKey(command, head, dirtyFingerprint, executionRoot)
|
|
|
293
345
|
* getting valid caching today — they are getting one answer forever.
|
|
294
346
|
*
|
|
295
347
|
* @task T12190 (gh#1419)
|
|
348
|
+
* @task T12989
|
|
296
349
|
*/
|
|
297
350
|
export function isEntryUsable(entry) {
|
|
298
351
|
for (const field of TOOL_RUN_IDENTITY_FIELDS) {
|
|
@@ -300,8 +353,97 @@ export function isEntryUsable(entry) {
|
|
|
300
353
|
if (value === null || value === undefined)
|
|
301
354
|
return false;
|
|
302
355
|
}
|
|
356
|
+
if (entry.resourceKill !== undefined && entry.resourceKill !== null)
|
|
357
|
+
return false;
|
|
303
358
|
return entry.exitCode !== null && entry.exitCode !== undefined;
|
|
304
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Signals whose `128 + n` exit status means the run was killed rather than
|
|
362
|
+
* finishing, when a shell, `pnpm` or a wrapper turns the signal into an exit
|
|
363
|
+
* code: termination (`TERM`, `INT`, `HUP`), the kernel or cgroup OOM killer's
|
|
364
|
+
* `KILL`, V8's `abort()` on heap exhaustion (`ABRT`), and the CPU and
|
|
365
|
+
* file-size rlimits. Numbers come from `os.constants.signals`, so they match
|
|
366
|
+
* the platform.
|
|
367
|
+
*
|
|
368
|
+
* `SEGV` and `BUS` are deliberately absent: a crash in native code can be a
|
|
369
|
+
* deterministic bug in the code under test, and such a red stays cacheable.
|
|
370
|
+
*
|
|
371
|
+
* @task T12989
|
|
372
|
+
*/
|
|
373
|
+
const KILL_SIGNALS = [
|
|
374
|
+
'SIGHUP',
|
|
375
|
+
'SIGINT',
|
|
376
|
+
'SIGABRT',
|
|
377
|
+
'SIGKILL',
|
|
378
|
+
'SIGTERM',
|
|
379
|
+
'SIGXCPU',
|
|
380
|
+
'SIGXFSZ',
|
|
381
|
+
];
|
|
382
|
+
/**
|
|
383
|
+
* Output only a run that ran out of memory prints: V8's fatal heap messages,
|
|
384
|
+
* Node's worker heap limit, the OS refusing an allocation, and vitest's report
|
|
385
|
+
* of a pool worker that vanished — what the kernel OOM-killing one fork looks
|
|
386
|
+
* like from the pool, which then exits 1 like a failing assertion.
|
|
387
|
+
*
|
|
388
|
+
* Matched as literal substrings, and only on a NON-ZERO exit. A false match
|
|
389
|
+
* (a failing test that happens to print one of these) costs a re-run, never a
|
|
390
|
+
* wrong verdict: the exit code is still reported, just not cached.
|
|
391
|
+
*
|
|
392
|
+
* @task T12989
|
|
393
|
+
*/
|
|
394
|
+
const RESOURCE_KILL_MARKERS = [
|
|
395
|
+
'JavaScript heap out of memory',
|
|
396
|
+
'Reached heap limit',
|
|
397
|
+
'Ineffective mark-compacts near heap limit',
|
|
398
|
+
'ERR_WORKER_OUT_OF_MEMORY',
|
|
399
|
+
'Worker terminated due to reaching memory limit',
|
|
400
|
+
'Worker exited unexpectedly',
|
|
401
|
+
'ENOMEM',
|
|
402
|
+
'Cannot allocate memory',
|
|
403
|
+
];
|
|
404
|
+
/**
|
|
405
|
+
* Why a finished run was killed for resources, or `null` when its outcome is
|
|
406
|
+
* a real result.
|
|
407
|
+
*
|
|
408
|
+
* Decides from the exit and the captured output, in order:
|
|
409
|
+
*
|
|
410
|
+
* 1. a terminating `signal` — the process did not exit on its own;
|
|
411
|
+
* 2. an exit code of `128 + n` for a {@link KILL_SIGNALS} signal — the same
|
|
412
|
+
* kill, reported by a wrapper (`sh` exits 137 for a `SIGKILL`ed child);
|
|
413
|
+
* 3. a non-zero exit whose output carries a {@link RESOURCE_KILL_MARKERS}
|
|
414
|
+
* marker — vitest catches a worker's heap OOM and exits 1.
|
|
415
|
+
*
|
|
416
|
+
* Exit 0 is always a result, and so is a plain non-zero exit such as a failing
|
|
417
|
+
* assertion's 1: that red stays cacheable for failed-first reruns. A run that
|
|
418
|
+
* never started (`exitCode` and `signal` both null) is not a kill; it is
|
|
419
|
+
* reported, and refused by {@link isEntryUsable}, as an unknown outcome.
|
|
420
|
+
*
|
|
421
|
+
* @param run - The finished run's exit code, signal and captured output.
|
|
422
|
+
* @returns A short reason, e.g. `exit 137 (128 + SIGKILL)`, or `null`.
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* resourceKillReason({ exitCode: 1, signal: null, stdout: '', stderr: 'FAIL a.test.ts' }); // null
|
|
427
|
+
* resourceKillReason({ exitCode: 137, signal: null, stdout: '', stderr: '' });
|
|
428
|
+
* // → 'exit 137 (128 + SIGKILL)'
|
|
429
|
+
* ```
|
|
430
|
+
*
|
|
431
|
+
* @task T12989
|
|
432
|
+
*/
|
|
433
|
+
export function resourceKillReason(run) {
|
|
434
|
+
if (run.signal !== null)
|
|
435
|
+
return `killed by ${run.signal}`;
|
|
436
|
+
if (run.exitCode === null || run.exitCode === 0)
|
|
437
|
+
return null;
|
|
438
|
+
for (const name of KILL_SIGNALS) {
|
|
439
|
+
if (run.exitCode === 128 + osConstants.signals[name]) {
|
|
440
|
+
return `exit ${run.exitCode} (128 + ${name})`;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const output = `${run.stdout}\n${run.stderr}`;
|
|
444
|
+
const marker = RESOURCE_KILL_MARKERS.find((m) => output.includes(m));
|
|
445
|
+
return marker === undefined ? null : `output reports "${marker}"`;
|
|
446
|
+
}
|
|
305
447
|
/**
|
|
306
448
|
* Maximum bytes retained from a child's stdout / stderr stream during
|
|
307
449
|
* spawn. The tool-evidence atom only needs the trailing 512 bytes for
|
|
@@ -409,6 +551,77 @@ function killProcessTree(pid, signal) {
|
|
|
409
551
|
}
|
|
410
552
|
}
|
|
411
553
|
}
|
|
554
|
+
/** Signals whose default action would end cleo and orphan the tool groups it started. */
|
|
555
|
+
const TERMINATION_SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM'];
|
|
556
|
+
let terminationCleanupInstalled = false;
|
|
557
|
+
/** SIGTERM every tool group this process started that is still running. */
|
|
558
|
+
function terminateActiveToolGroups() {
|
|
559
|
+
for (const pgid of activeToolGroups())
|
|
560
|
+
killProcessTree(pgid, 'SIGTERM');
|
|
561
|
+
}
|
|
562
|
+
function onTerminationSignal(signal) {
|
|
563
|
+
if (signal === 'SIGHUP' || signal === 'SIGINT' || signal === 'SIGTERM') {
|
|
564
|
+
terminateToolGroupsOnSignal(signal);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Add or remove the listeners that pass the end of this process on to the
|
|
569
|
+
* tools it runs: the termination signals, and `exit` (a `process.exit()` or
|
|
570
|
+
* an uncaught exception, e.g. a lock's `onCompromised` throwing from a timer).
|
|
571
|
+
*/
|
|
572
|
+
function setTerminationCleanup(on) {
|
|
573
|
+
if (on === terminationCleanupInstalled)
|
|
574
|
+
return;
|
|
575
|
+
terminationCleanupInstalled = on;
|
|
576
|
+
for (const signal of TERMINATION_SIGNALS) {
|
|
577
|
+
if (on)
|
|
578
|
+
process.on(signal, onTerminationSignal);
|
|
579
|
+
else
|
|
580
|
+
process.off(signal, onTerminationSignal);
|
|
581
|
+
}
|
|
582
|
+
if (on)
|
|
583
|
+
process.on('exit', terminateToolGroupsOnExit);
|
|
584
|
+
else
|
|
585
|
+
process.off('exit', terminateToolGroupsOnExit);
|
|
586
|
+
}
|
|
587
|
+
/** Install the cleanup while a tool group runs; remove it once none does. */
|
|
588
|
+
function syncTerminationCleanup() {
|
|
589
|
+
setTerminationCleanup(activeToolGroups().length > 0);
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* SIGTERM every tool group this process started, then re-raise the signal to
|
|
593
|
+
* this process so it ends exactly as it would have without us (T12963).
|
|
594
|
+
*
|
|
595
|
+
* Tools run detached, in their own process group, so a signal that ends cleo
|
|
596
|
+
* (Ctrl-C reaches only the terminal's foreground group) never reaches them:
|
|
597
|
+
* the tool kept running, and the slot it held looked free once cleo was gone.
|
|
598
|
+
*
|
|
599
|
+
* Any listener at all suppresses a signal's default action, and
|
|
600
|
+
* `proper-lockfile` loads `signal-exit`, which re-raises only when its own
|
|
601
|
+
* listeners are the last ones left. So ours removes itself and re-raises:
|
|
602
|
+
* `signal-exit` then releases its locks and the process dies by the signal.
|
|
603
|
+
* Another listener of the signal sees it twice.
|
|
604
|
+
*
|
|
605
|
+
* @param signal - The signal received.
|
|
606
|
+
*
|
|
607
|
+
* @internal Exported for tests.
|
|
608
|
+
* @task T12963
|
|
609
|
+
*/
|
|
610
|
+
export function terminateToolGroupsOnSignal(signal) {
|
|
611
|
+
terminateActiveToolGroups();
|
|
612
|
+
setTerminationCleanup(false);
|
|
613
|
+
process.kill(process.pid, signal);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The `exit` listener: SIGTERM every tool group still running. `exit`
|
|
617
|
+
* listeners must be synchronous, and `process.kill` is.
|
|
618
|
+
*
|
|
619
|
+
* @internal Exported for tests.
|
|
620
|
+
* @task T12963
|
|
621
|
+
*/
|
|
622
|
+
export function terminateToolGroupsOnExit() {
|
|
623
|
+
terminateActiveToolGroups();
|
|
624
|
+
}
|
|
412
625
|
function spawnCmd(cmd, args, cwd, spawnTimeoutMs, envOverlay) {
|
|
413
626
|
return new Promise((resolve) => {
|
|
414
627
|
const stdoutBuf = new TailBuffer(STREAM_TAIL_CAP_BYTES);
|
|
@@ -429,6 +642,10 @@ function spawnCmd(cmd, args, cwd, spawnTimeoutMs, envOverlay) {
|
|
|
429
642
|
// preventing the `close` event from ever firing.
|
|
430
643
|
detached: true,
|
|
431
644
|
});
|
|
645
|
+
// T12963: while this group runs, every slot this process holds stays held
|
|
646
|
+
// even if cleo dies first, and a terminating signal is passed on to it.
|
|
647
|
+
const untrackGroup = trackToolGroup(child.pid);
|
|
648
|
+
syncTerminationCleanup();
|
|
432
649
|
child.stdout?.on('data', (d) => {
|
|
433
650
|
stdoutBuf.append(d);
|
|
434
651
|
});
|
|
@@ -447,6 +664,8 @@ function spawnCmd(cmd, args, cwd, spawnTimeoutMs, envOverlay) {
|
|
|
447
664
|
let spawnError = null;
|
|
448
665
|
const finalise = (exitCode, signal) => {
|
|
449
666
|
clearTimers();
|
|
667
|
+
untrackGroup();
|
|
668
|
+
syncTerminationCleanup();
|
|
450
669
|
const stderr = stderrBuf.toString();
|
|
451
670
|
resolve({
|
|
452
671
|
exitCode,
|
|
@@ -510,6 +729,11 @@ export async function captureHead(projectRoot) {
|
|
|
510
729
|
* why NOT `git status --porcelain`). Returns `null` for
|
|
511
730
|
* non-git roots.
|
|
512
731
|
*
|
|
732
|
+
* Since T12958 this is NOT part of the tool-cache key — {@link captureTreeHash}
|
|
733
|
+
* replaced it there, and (unlike this function) counts untracked, not-ignored
|
|
734
|
+
* files. It remains the dirty-tree identity for the gate-result cache, where
|
|
735
|
+
* the tracked-only rule below still applies.
|
|
736
|
+
*
|
|
513
737
|
* Two repos with identical tracked content but different uncommitted edits
|
|
514
738
|
* produce different fingerprints — so editing a tracked file before
|
|
515
739
|
* re-verifying always invalidates the cache for tools sensitive to that file.
|
|
@@ -620,6 +844,141 @@ export async function captureDirtyFingerprint(projectRoot) {
|
|
|
620
844
|
// that tradeoff exists to prevent.
|
|
621
845
|
return hashGitStdout(['diff', 'HEAD', ...FINGERPRINT_PATHSPEC], projectRoot);
|
|
622
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* Untracked paths {@link captureTreeHash} leaves out: CLEO's runtime state
|
|
849
|
+
* (`.cleo/`, written on every command) and the well-known untracked outputs
|
|
850
|
+
* of test and build tools that are not always gitignored. Neither a CLEO
|
|
851
|
+
* command nor the tool under test can therefore move the key it is stored
|
|
852
|
+
* under — the self-invalidation gh#1221 was about.
|
|
853
|
+
*
|
|
854
|
+
* Applies to UNTRACKED additions only. A tracked file under one of these
|
|
855
|
+
* paths (`.cleo/canon.yml`, a lint baseline, a tracked fixture `.log`) is
|
|
856
|
+
* source: its uncommitted edits always count.
|
|
857
|
+
*/
|
|
858
|
+
function isUntrackedRuntimeOutput(path) {
|
|
859
|
+
if (path.startsWith('.cleo/'))
|
|
860
|
+
return true;
|
|
861
|
+
if (path.endsWith('.log') || path.endsWith('.tsbuildinfo'))
|
|
862
|
+
return true;
|
|
863
|
+
return path
|
|
864
|
+
.split('/')
|
|
865
|
+
.some((seg) => ['coverage', '.vitest', '.nyc_output', '.turbo', 'test-results'].includes(seg));
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Untracked files larger than this are left out of {@link captureTreeHash}
|
|
869
|
+
* rather than hashed into the object database on every call. Such files are
|
|
870
|
+
* rarely source; a change to one does not move the key (force a run with
|
|
871
|
+
* `CLEO_EVIDENCE_FRESH=1`).
|
|
872
|
+
*/
|
|
873
|
+
export const MAX_UNTRACKED_HASH_BYTES = 5 * 1024 * 1024;
|
|
874
|
+
/**
|
|
875
|
+
* Capture the git tree hash of the working tree's SOURCE content: HEAD's tree
|
|
876
|
+
* with every uncommitted change applied — staged or not, to tracked files and
|
|
877
|
+
* to untracked files that are not gitignored. Returns `null` for non-git
|
|
878
|
+
* roots or on any git failure (the entry is then unusable and nothing is
|
|
879
|
+
* cached).
|
|
880
|
+
*
|
|
881
|
+
* ## How (T12958)
|
|
882
|
+
*
|
|
883
|
+
* 1. Copy the checkout's own index to a private temporary file, preserving
|
|
884
|
+
* its atime/mtime. The copy keeps the index's stat cache, so git re-hashes
|
|
885
|
+
* only files whose stat changed. The preserved mtime matters: git's
|
|
886
|
+
* racy-clean check compares each entry's mtime with the INDEX FILE's
|
|
887
|
+
* mtime, and a fresh mtime on the copy would let a same-size edit made in
|
|
888
|
+
* the same timestamp tick as the last index write read as clean. The real
|
|
889
|
+
* index is never touched.
|
|
890
|
+
* 2. `GIT_INDEX_FILE=<copy> git add -u -- .` stages every modified or deleted
|
|
891
|
+
* TRACKED path — all of them, including tracked files under `.cleo/`.
|
|
892
|
+
* 3. `git ls-files -o --exclude-standard -z` lists untracked, not-ignored
|
|
893
|
+
* files; those that are not runtime output ({@link isUntrackedRuntimeOutput})
|
|
894
|
+
* and not over {@link MAX_UNTRACKED_HASH_BYTES} are added with
|
|
895
|
+
* `git update-index --add -z --stdin`.
|
|
896
|
+
* 4. `GIT_INDEX_FILE=<copy> git write-tree` prints the tree.
|
|
897
|
+
*
|
|
898
|
+
* A clean checkout yields exactly `HEAD^{tree}`. Two worktrees with the same
|
|
899
|
+
* source yield the same hash wherever they live, and an empty commit, an
|
|
900
|
+
* amend of only the message, or a rebase that reproduces the same content
|
|
901
|
+
* leave it unchanged.
|
|
902
|
+
*
|
|
903
|
+
* ## Untracked files (review of #1774, supersedes the gh#1221 tracked-only rule)
|
|
904
|
+
*
|
|
905
|
+
* An untracked, not-ignored file IS part of the hash. Tracked-only was safe
|
|
906
|
+
* while the key also carried the execution root; once the key is shared
|
|
907
|
+
* across worktrees it is a false pass: a worker's new, uncommitted module and
|
|
908
|
+
* failing test leave the tracked tree equal to main's, and main's cached pass
|
|
909
|
+
* would be served without the worker's tests ever running.
|
|
910
|
+
*
|
|
911
|
+
* Side effect: staging writes blob objects for dirty and new files into the
|
|
912
|
+
* repository's object database, and `write-tree` writes tree objects. Both
|
|
913
|
+
* are ordinary unreferenced loose objects: `git gc` prunes them after
|
|
914
|
+
* `gc.pruneExpire` (two weeks by default), so `git ls-tree <treeHash>` as an
|
|
915
|
+
* audit of an old entry is best-effort.
|
|
916
|
+
*
|
|
917
|
+
* @param root - Directory to fingerprint (the execution root).
|
|
918
|
+
* @returns 40/64 hex chars of the tree object id, or `null`.
|
|
919
|
+
*
|
|
920
|
+
* @task T12958
|
|
921
|
+
*/
|
|
922
|
+
export async function captureTreeHash(root) {
|
|
923
|
+
// `--absolute-git-dir`, not `--git-path index`: the latter answers with an
|
|
924
|
+
// inherited `GIT_INDEX_FILE` (e.g. inside a git hook), which is not this
|
|
925
|
+
// checkout's index.
|
|
926
|
+
const gitDir = await spawnCmd('git', ['rev-parse', '--absolute-git-dir'], root);
|
|
927
|
+
if (gitDir.exitCode !== 0)
|
|
928
|
+
return null;
|
|
929
|
+
const realIndex = join(gitDir.stdout.trim(), 'index');
|
|
930
|
+
let tmpDir = null;
|
|
931
|
+
try {
|
|
932
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'cleo-tree-'));
|
|
933
|
+
const indexFile = join(tmpDir, 'index');
|
|
934
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
935
|
+
if (existsSync(realIndex)) {
|
|
936
|
+
copyFileSync(realIndex, indexFile);
|
|
937
|
+
const st = statSync(realIndex);
|
|
938
|
+
utimesSync(indexFile, st.atime, st.mtime);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
// No index yet: seed from HEAD. An unborn HEAD leaves the index empty,
|
|
942
|
+
// which is correct — nothing is tracked.
|
|
943
|
+
await spawnCmd('git', ['read-tree', 'HEAD'], root, undefined, env);
|
|
944
|
+
}
|
|
945
|
+
const tracked = await spawnCmd('git', ['add', '-u', '--', '.'], root, undefined, env);
|
|
946
|
+
if (tracked.exitCode !== 0)
|
|
947
|
+
return null;
|
|
948
|
+
const listed = execFileSync('git', ['ls-files', '-o', '--exclude-standard', '-z', '--', '.'], {
|
|
949
|
+
cwd: root,
|
|
950
|
+
encoding: 'utf-8',
|
|
951
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
952
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
953
|
+
env: { ...process.env, ...env },
|
|
954
|
+
});
|
|
955
|
+
const untracked = listed.split('\0').filter((p) => {
|
|
956
|
+
if (p === '' || isUntrackedRuntimeOutput(p))
|
|
957
|
+
return false;
|
|
958
|
+
const st = statSync(join(root, p), { throwIfNoEntry: false });
|
|
959
|
+
return st?.isFile() === true && st.size <= MAX_UNTRACKED_HASH_BYTES;
|
|
960
|
+
});
|
|
961
|
+
if (untracked.length > 0) {
|
|
962
|
+
execFileSync('git', ['update-index', '--add', '-z', '--stdin'], {
|
|
963
|
+
cwd: root,
|
|
964
|
+
input: `${untracked.join('\0')}\0`,
|
|
965
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
966
|
+
env: { ...process.env, ...env },
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
const tree = await spawnCmd('git', ['write-tree'], root, undefined, env);
|
|
970
|
+
if (tree.exitCode !== 0)
|
|
971
|
+
return null;
|
|
972
|
+
return tree.stdout.trim() || null;
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
finally {
|
|
978
|
+
if (tmpDir !== null)
|
|
979
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
980
|
+
}
|
|
981
|
+
}
|
|
623
982
|
// ---------------------------------------------------------------------------
|
|
624
983
|
// Cache directory + entry IO
|
|
625
984
|
// ---------------------------------------------------------------------------
|
|
@@ -652,12 +1011,9 @@ export function readCacheEntry(projectRoot, key) {
|
|
|
652
1011
|
return null;
|
|
653
1012
|
try {
|
|
654
1013
|
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
655
|
-
// Schema gate. Bumped to 2 by gh#1419; every entry
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
// reimplemented it as a bespoke null-check on their own field, while this
|
|
659
|
-
// counter sat unused at 1 through both.
|
|
660
|
-
if (parsed.schemaVersion !== 2 || parsed.key !== key)
|
|
1014
|
+
// Schema gate. Bumped to 2 by gh#1419 and to 3 by T12958; every entry
|
|
1015
|
+
// written under an older identity is refused here, on this one line.
|
|
1016
|
+
if (parsed.schemaVersion !== TOOL_CACHE_SCHEMA_VERSION || parsed.key !== key)
|
|
661
1017
|
return null;
|
|
662
1018
|
// A placeholder written by `runToolCached` to satisfy proper-lockfile's
|
|
663
1019
|
// "file must exist" requirement. It carries no real result — treat as
|
|
@@ -670,18 +1026,10 @@ export function readCacheEntry(projectRoot, key) {
|
|
|
670
1026
|
// shared verbatim with the persist guard in `runToolCached`.
|
|
671
1027
|
if (!isEntryUsable(parsed))
|
|
672
1028
|
return null;
|
|
673
|
-
// gh#1419: refuse a hit whose
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
// field survey actually found: 8 entries produced in worktrees that had
|
|
678
|
-
// since been deleted, all `exitCode: 0`, all still servable. A stale
|
|
679
|
-
// result is merely wrong; a result whose tree no longer exists is
|
|
680
|
-
// UNFALSIFIABLE — there is nowhere left to go and check what ran.
|
|
681
|
-
//
|
|
682
|
-
// This is the one rule that is genuinely read-only. At write time the
|
|
683
|
-
// directory exists by construction, so it cannot live in `isEntryUsable`
|
|
684
|
-
// alongside the rules both paths share.
|
|
1029
|
+
// gh#1419: refuse a hit whose recorded tree is GONE. Kept under the
|
|
1030
|
+
// content key (review of #1774): the tree hash and environment
|
|
1031
|
+
// fingerprint identify what ran, but a result whose checkout no longer
|
|
1032
|
+
// exists cannot be re-examined for anything the fingerprints do not cover.
|
|
685
1033
|
if (!existsSync(parsed.executionRoot))
|
|
686
1034
|
return null;
|
|
687
1035
|
return parsed;
|
|
@@ -690,6 +1038,49 @@ export function readCacheEntry(projectRoot, key) {
|
|
|
690
1038
|
return null;
|
|
691
1039
|
}
|
|
692
1040
|
}
|
|
1041
|
+
const legacySwept = new Set();
|
|
1042
|
+
/**
|
|
1043
|
+
* Delete tool-cache entries written under an older schema, once per process
|
|
1044
|
+
* and store root.
|
|
1045
|
+
*
|
|
1046
|
+
* Their keys can never be computed again (the identity changed with the
|
|
1047
|
+
* schema), so nothing would ever read or overwrite them. Only files named
|
|
1048
|
+
* like a tool-cache key (32 hex chars) are considered; the `pr-`, `gate-`,
|
|
1049
|
+
* `reval-` and `failed-first-` files sharing the directory are left alone.
|
|
1050
|
+
*
|
|
1051
|
+
* @task T12958
|
|
1052
|
+
*/
|
|
1053
|
+
function sweepLegacyEntriesOnce(projectRoot) {
|
|
1054
|
+
if (legacySwept.has(projectRoot))
|
|
1055
|
+
return;
|
|
1056
|
+
legacySwept.add(projectRoot);
|
|
1057
|
+
const dir = join(projectRoot, '.cleo', 'cache', 'evidence');
|
|
1058
|
+
let names;
|
|
1059
|
+
try {
|
|
1060
|
+
names = readdirSync(dir);
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
for (const name of names) {
|
|
1066
|
+
if (!/^[0-9a-f]{32}\.json$/.test(name))
|
|
1067
|
+
continue;
|
|
1068
|
+
// A sibling lock means a process may be working on this key right now.
|
|
1069
|
+
if (existsSync(join(dir, `${name}.lock`)))
|
|
1070
|
+
continue;
|
|
1071
|
+
try {
|
|
1072
|
+
const parsed = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
|
|
1073
|
+
if (parsed.pending === true)
|
|
1074
|
+
continue;
|
|
1075
|
+
if (parsed.schemaVersion !== TOOL_CACHE_SCHEMA_VERSION) {
|
|
1076
|
+
rmSync(join(dir, name), { force: true });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// unreadable or mid-write: leave it
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
693
1084
|
/**
|
|
694
1085
|
* Atomically write a cache entry: writes to `.tmp` then renames so concurrent
|
|
695
1086
|
* readers never observe a half-written file.
|
|
@@ -703,20 +1094,81 @@ export function writeCacheEntry(projectRoot, entry) {
|
|
|
703
1094
|
writeFileSync(tmpPath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
704
1095
|
renameSync(tmpPath, finalPath);
|
|
705
1096
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1097
|
+
/**
|
|
1098
|
+
* Run the planned focused invocations in order, stopping at the first one
|
|
1099
|
+
* that decides anything (T12961). Each runs under the same memory bound and
|
|
1100
|
+
* heavy-tool env as the normal command, and inside the caller's semaphore
|
|
1101
|
+
* slot and per-key lock.
|
|
1102
|
+
*
|
|
1103
|
+
* Only a real, non-zero exit counts as `failed`. A spawn error, a signal kill,
|
|
1104
|
+
* any other resource kill ({@link resourceKillReason}: exit 137, a heap OOM)
|
|
1105
|
+
* or vitest reporting that the filters matched nothing is `inconclusive`, and
|
|
1106
|
+
* the caller falls back to the normal command — a focused run may shorten a
|
|
1107
|
+
* red result but must never invent one.
|
|
1108
|
+
*/
|
|
1109
|
+
async function runFocused(command, plan, executionRoot, spawnTimeoutMs, toolEnv) {
|
|
1110
|
+
for (const run of plan) {
|
|
1111
|
+
const limited = withMemoryLimit(command.canonical, run.cmd, run.args, { executionRoot });
|
|
1112
|
+
const startedAt = Date.now();
|
|
1113
|
+
const result = await spawnCmd(limited.cmd, [...limited.args], run.cwd, spawnTimeoutMs, toolEnv);
|
|
1114
|
+
const durationMs = Date.now() - startedAt;
|
|
1115
|
+
if (result.timedOut)
|
|
1116
|
+
return { kind: 'timedOut', result, durationMs, cwd: run.cwd };
|
|
1117
|
+
const harnessFailure = confinementStartupFailure(result.stderr, limited.confined);
|
|
1118
|
+
if (harnessFailure !== null)
|
|
1119
|
+
return { kind: 'harness', result, durationMs, harnessFailure };
|
|
1120
|
+
if (result.exitCode === null || resourceKillReason(result) !== null) {
|
|
1121
|
+
return { kind: 'inconclusive' };
|
|
1122
|
+
}
|
|
1123
|
+
if (result.exitCode !== 0) {
|
|
1124
|
+
if (reportsNoTestFiles(`${result.stdout}\n${result.stderr}`))
|
|
1125
|
+
return { kind: 'inconclusive' };
|
|
1126
|
+
return { kind: 'failed', result, durationMs, cwd: run.cwd };
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return { kind: 'passed' };
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Tracked files of `root`, relative to it, for failing-file suffix lookup.
|
|
1133
|
+
* Empty on any git failure.
|
|
1134
|
+
*/
|
|
1135
|
+
function listTrackedFiles(root) {
|
|
1136
|
+
try {
|
|
1137
|
+
return execFileSync('git', ['ls-files', '-z'], {
|
|
1138
|
+
cwd: root,
|
|
1139
|
+
encoding: 'utf-8',
|
|
1140
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1141
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1142
|
+
})
|
|
1143
|
+
.split('\0')
|
|
1144
|
+
.filter(Boolean);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
709
1150
|
/**
|
|
710
1151
|
* Run a resolved tool command with caching + cross-process locking.
|
|
711
1152
|
*
|
|
712
1153
|
* Flow:
|
|
713
1154
|
*
|
|
714
|
-
* 1. Compute cache key (canonical+cmd+args+
|
|
1155
|
+
* 1. Compute cache key (canonical+cmd+args+treeHash+envFingerprint+
|
|
1156
|
+
* resourceEnv).
|
|
715
1157
|
* 2. If a fresh entry exists → return it (no spawn).
|
|
716
|
-
* 3. Acquire a `proper-lockfile` on the cache entry path.
|
|
1158
|
+
* 3. Acquire a `proper-lockfile` on the cache entry path. When another
|
|
1159
|
+
* caller holds it (same command, same content — possibly another
|
|
1160
|
+
* worktree), wait for its result and reuse it (T12958).
|
|
717
1161
|
* 4. Re-check cache inside the lock (another process may have written it
|
|
718
1162
|
* while we were waiting).
|
|
719
|
-
* 5.
|
|
1163
|
+
* 5. For `test`, when the previous run in this execution root failed on a
|
|
1164
|
+
* different tree, re-run only its failing files first; if they still
|
|
1165
|
+
* fail, return that (uncached, `scope: 'focused'`) failure without the
|
|
1166
|
+
* normal run (T12961).
|
|
1167
|
+
* 6. Spawn the tool, capture stdout/stderr tails. For a failing `test`,
|
|
1168
|
+
* re-run the FULL command once; a pass there is a pass marked `flaky`
|
|
1169
|
+
* (T12961). Write the entry, return. A run killed for resources is
|
|
1170
|
+
* returned with `resourceKill` set and is neither retried, recorded for
|
|
1171
|
+
* failed-first, nor written (T12989).
|
|
720
1172
|
*
|
|
721
1173
|
* Locks are auto-released on success or failure. Stale locks are reaped per
|
|
722
1174
|
* the `lockStaleMs` option (default 10 min — long enough to cover a full
|
|
@@ -728,9 +1180,13 @@ export function writeCacheEntry(projectRoot, entry) {
|
|
|
728
1180
|
* @returns Result envelope with `exitCode`, `stdoutTail`, `cacheHit`, etc.
|
|
729
1181
|
*
|
|
730
1182
|
* @task T1534
|
|
1183
|
+
* @task T12958
|
|
1184
|
+
* @task T12961
|
|
1185
|
+
* @task T12989
|
|
731
1186
|
* @adr ADR-061
|
|
732
1187
|
*/
|
|
733
1188
|
export async function runToolCached(command, projectRoot, opts = {}) {
|
|
1189
|
+
const callStartedAt = Date.now();
|
|
734
1190
|
const tailBytes = opts.tailBytes ?? 512;
|
|
735
1191
|
const lockStaleMs = opts.lockStaleMs ?? 600_000;
|
|
736
1192
|
// T12105 / gh#1193: an explicit opts value (tests) wins; otherwise the
|
|
@@ -739,35 +1195,113 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
739
1195
|
// gh#1220/#1226/#1230: the tool runs in — and is fingerprinted against —
|
|
740
1196
|
// the caller's tree, which is NOT necessarily the store root. The cache
|
|
741
1197
|
// ENTRY still lives under `projectRoot` so every worktree shares one cache;
|
|
742
|
-
// that is sound because the key is content-addressed (
|
|
743
|
-
//
|
|
1198
|
+
// that is sound because the key is content-addressed (the tracked tree
|
|
1199
|
+
// hash, T12958), so two trees share an entry exactly when they hold the
|
|
1200
|
+
// same tracked code.
|
|
744
1201
|
const executionRoot = opts.executionRoot ?? projectRoot;
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
1202
|
+
const recordedRoot = normalizeExecutionRoot(executionRoot);
|
|
1203
|
+
// Escape hatch for anything the key cannot see (an ignored or excluded
|
|
1204
|
+
// path, environment state outside the fingerprint). An explicit option
|
|
1205
|
+
// always wins over the env var.
|
|
749
1206
|
const bypassCache = opts.bypassCache ?? process.env['CLEO_EVIDENCE_FRESH'] === '1';
|
|
1207
|
+
const treeHash = await captureTreeHash(executionRoot);
|
|
1208
|
+
const envFingerprint = captureEnvFingerprint(executionRoot, command.canonical);
|
|
1209
|
+
// T12989: the overlay is computed ONCE and both spawned with and keyed on,
|
|
1210
|
+
// so the key describes the heap and worker limits the run actually got.
|
|
1211
|
+
const toolEnv = heavyToolEnv(command.canonical);
|
|
1212
|
+
const resourceEnv = captureResourceEnv(command.canonical, process.env, toolEnv);
|
|
750
1213
|
const head = await captureHead(executionRoot);
|
|
751
|
-
const
|
|
752
|
-
const
|
|
1214
|
+
const key = computeCacheKey(command, treeHash, envFingerprint, resourceEnv);
|
|
1215
|
+
const makeEntry = (run) => ({
|
|
1216
|
+
schemaVersion: TOOL_CACHE_SCHEMA_VERSION,
|
|
1217
|
+
key,
|
|
1218
|
+
canonical: command.canonical,
|
|
1219
|
+
displayName: command.displayName,
|
|
1220
|
+
cmd: command.cmd,
|
|
1221
|
+
args: command.args,
|
|
1222
|
+
source: command.source,
|
|
1223
|
+
treeHash,
|
|
1224
|
+
envFingerprint,
|
|
1225
|
+
resourceEnv,
|
|
1226
|
+
head,
|
|
1227
|
+
executionRoot: recordedRoot,
|
|
1228
|
+
...run,
|
|
1229
|
+
capturedAt: new Date().toISOString(),
|
|
1230
|
+
});
|
|
1231
|
+
const hit = (entry) => ({
|
|
1232
|
+
exitCode: entry.exitCode,
|
|
1233
|
+
signal: entry.signal ?? null,
|
|
1234
|
+
stdoutTail: entry.stdoutTail,
|
|
1235
|
+
stderrTail: entry.stderrTail,
|
|
1236
|
+
durationMs: entry.durationMs,
|
|
1237
|
+
cacheHit: true,
|
|
1238
|
+
timedOut: false,
|
|
1239
|
+
lockBusy: false,
|
|
1240
|
+
harnessFailure: null,
|
|
1241
|
+
resourceKill: null,
|
|
1242
|
+
executionRoot,
|
|
1243
|
+
treeHash: entry.treeHash,
|
|
1244
|
+
...(entry.failedFirst ? { failedFirst: entry.failedFirst } : {}),
|
|
1245
|
+
...(entry.flaky ? { flaky: entry.flaky } : {}),
|
|
1246
|
+
entry,
|
|
1247
|
+
});
|
|
1248
|
+
// T12025: when the child exceeded its wall-clock deadline, do NOT persist a
|
|
1249
|
+
// cache entry — the run produced no real result. The lock is released via
|
|
1250
|
+
// withLock's finally so a subsequent retry can acquire it and attempt a
|
|
1251
|
+
// fresh spawn from the same pending entry.
|
|
1252
|
+
const timedOutResult = (result, durationMs, failedFirst) => ({
|
|
1253
|
+
exitCode: result.exitCode,
|
|
1254
|
+
signal: result.signal,
|
|
1255
|
+
stdoutTail: tailString(result.stdout, tailBytes),
|
|
1256
|
+
stderrTail: tailString(result.stderr, tailBytes),
|
|
1257
|
+
durationMs,
|
|
1258
|
+
cacheHit: false,
|
|
1259
|
+
timedOut: true,
|
|
1260
|
+
lockBusy: false,
|
|
1261
|
+
harnessFailure: null,
|
|
1262
|
+
resourceKill: null,
|
|
1263
|
+
executionRoot,
|
|
1264
|
+
treeHash,
|
|
1265
|
+
...(failedFirst ? { failedFirst } : {}),
|
|
1266
|
+
entry: makeEntry({
|
|
1267
|
+
exitCode: null,
|
|
1268
|
+
signal: result.signal,
|
|
1269
|
+
stdoutTail: '',
|
|
1270
|
+
stderrTail: '',
|
|
1271
|
+
durationMs,
|
|
1272
|
+
}),
|
|
1273
|
+
});
|
|
1274
|
+
// gh#1397: a failure of CLEO's own confinement WRAPPER is not a verdict on
|
|
1275
|
+
// the project's tool. Like the `timedOut` branch, this run produced no
|
|
1276
|
+
// result to cache — caching one would serve a fabricated "your tests
|
|
1277
|
+
// failed" from a key that cannot rotate until the tree changes.
|
|
1278
|
+
const harnessResult = (result, durationMs, harnessFailure, failedFirst) => ({
|
|
1279
|
+
exitCode: result.exitCode,
|
|
1280
|
+
signal: result.signal,
|
|
1281
|
+
stdoutTail: tailString(result.stdout, tailBytes),
|
|
1282
|
+
stderrTail: tailString(result.stderr, tailBytes),
|
|
1283
|
+
durationMs,
|
|
1284
|
+
cacheHit: false,
|
|
1285
|
+
timedOut: false,
|
|
1286
|
+
lockBusy: false,
|
|
1287
|
+
executionRoot,
|
|
1288
|
+
treeHash,
|
|
1289
|
+
harnessFailure,
|
|
1290
|
+
resourceKill: null,
|
|
1291
|
+
...(failedFirst ? { failedFirst } : {}),
|
|
1292
|
+
entry: makeEntry({
|
|
1293
|
+
exitCode: null,
|
|
1294
|
+
signal: result.signal,
|
|
1295
|
+
stdoutTail: '',
|
|
1296
|
+
stderrTail: tailString(result.stderr, tailBytes),
|
|
1297
|
+
durationMs,
|
|
1298
|
+
}),
|
|
1299
|
+
});
|
|
753
1300
|
// Fast path — fresh cache hit
|
|
754
1301
|
if (!bypassCache) {
|
|
755
1302
|
const existing = readCacheEntry(projectRoot, key);
|
|
756
|
-
if (existing)
|
|
757
|
-
return
|
|
758
|
-
exitCode: existing.exitCode,
|
|
759
|
-
signal: existing.signal ?? null,
|
|
760
|
-
stdoutTail: existing.stdoutTail,
|
|
761
|
-
stderrTail: existing.stderrTail,
|
|
762
|
-
durationMs: existing.durationMs,
|
|
763
|
-
cacheHit: true,
|
|
764
|
-
timedOut: false,
|
|
765
|
-
lockBusy: false,
|
|
766
|
-
harnessFailure: null,
|
|
767
|
-
executionRoot,
|
|
768
|
-
entry: existing,
|
|
769
|
-
};
|
|
770
|
-
}
|
|
1303
|
+
if (existing)
|
|
1304
|
+
return hit(existing);
|
|
771
1305
|
}
|
|
772
1306
|
// Slow path:
|
|
773
1307
|
// 1. Acquire the global per-tool semaphore (bounds total concurrent
|
|
@@ -785,237 +1319,312 @@ export async function runToolCached(command, projectRoot, opts = {}) {
|
|
|
785
1319
|
ensureCacheDir(projectRoot);
|
|
786
1320
|
const cachePath = cacheEntryPath(projectRoot, key);
|
|
787
1321
|
if (!existsSync(cachePath)) {
|
|
788
|
-
writeFileSync(cachePath, JSON.stringify({ schemaVersion:
|
|
1322
|
+
writeFileSync(cachePath, JSON.stringify({ schemaVersion: TOOL_CACHE_SCHEMA_VERSION, key, pending: true }), 'utf-8');
|
|
789
1323
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1324
|
+
// The body run under the per-key lock: re-check, failed-first, spawn.
|
|
1325
|
+
const runLocked = async () => {
|
|
1326
|
+
// Inside the lock — re-check the cache. If another process beat us to
|
|
1327
|
+
// it, prefer its result.
|
|
1328
|
+
if (!bypassCache) {
|
|
1329
|
+
const fresh = readCacheEntry(projectRoot, key);
|
|
1330
|
+
if (fresh)
|
|
1331
|
+
return hit(fresh);
|
|
1332
|
+
}
|
|
1333
|
+
sweepLegacyEntriesOnce(projectRoot);
|
|
1334
|
+
const failedFirstEnabled = FAILED_FIRST_TOOLS.has(command.canonical);
|
|
1335
|
+
let trackedCache = null;
|
|
1336
|
+
const tracked = () => {
|
|
1337
|
+
trackedCache ??= listTrackedFiles(executionRoot);
|
|
1338
|
+
return trackedCache;
|
|
1339
|
+
};
|
|
1340
|
+
// T12961: failed-first. When the last run of this tool in this tree
|
|
1341
|
+
// failed on a DIFFERENT tree (i.e. something changed since), re-run only
|
|
1342
|
+
// its failing files before the normal command.
|
|
1343
|
+
//
|
|
1344
|
+
// Liveness rules (review of #1774), so a pointer can never pin a tree red:
|
|
1345
|
+
// - `bypassCache` (`CLEO_EVIDENCE_FRESH=1`) skips failed-first entirely;
|
|
1346
|
+
// - a pointer recorded on THIS tree is not re-focused: the normal
|
|
1347
|
+
// command runs and decides (a file that fails only in isolation is
|
|
1348
|
+
// thereby re-judged by the real suite);
|
|
1349
|
+
// - a focused run with no parseable FAIL line (a startup or config
|
|
1350
|
+
// crash) is inconclusive and also runs the normal command.
|
|
1351
|
+
// A focused red is returned but NEVER cached under the normal command's
|
|
1352
|
+
// key: the suite did not run, so it must not be served as the suite's
|
|
1353
|
+
// result. Its record lives in the pointer (now on this tree), so the next
|
|
1354
|
+
// run here goes straight to the normal command.
|
|
1355
|
+
let failedFirst;
|
|
1356
|
+
if (failedFirstEnabled && !bypassCache) {
|
|
1357
|
+
const pointer = readFailedFirstPointer(projectRoot, command.canonical, recordedRoot);
|
|
1358
|
+
const plan = pointer && pointer.treeHash !== treeHash
|
|
1359
|
+
? planFocusedRuns(pointer.files, executionRoot)
|
|
1360
|
+
: null;
|
|
1361
|
+
if (pointer && plan) {
|
|
1362
|
+
const report = (outcome) => ({
|
|
1363
|
+
files: [...pointer.files],
|
|
1364
|
+
outcome,
|
|
1365
|
+
});
|
|
1366
|
+
const focused = await runFocused(command, plan, executionRoot, spawnTimeoutMs, toolEnv);
|
|
1367
|
+
switch (focused.kind) {
|
|
1368
|
+
case 'timedOut':
|
|
1369
|
+
return timedOutResult(focused.result, focused.durationMs, report('inconclusive'));
|
|
1370
|
+
case 'harness':
|
|
1371
|
+
return harnessResult(focused.result, focused.durationMs, focused.harnessFailure, report('inconclusive'));
|
|
1372
|
+
case 'failed': {
|
|
1373
|
+
const out = `${focused.result.stdout}\n${focused.result.stderr}`;
|
|
1374
|
+
const files = parseFailingTestFiles(out, executionRoot, tracked, focused.cwd);
|
|
1375
|
+
if (files.length === 0) {
|
|
1376
|
+
failedFirst = report('inconclusive');
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
writeFailedFirstPointer(projectRoot, command.canonical, recordedRoot, files, treeHash);
|
|
1380
|
+
const entry = makeEntry({
|
|
1381
|
+
exitCode: focused.result.exitCode,
|
|
1382
|
+
signal: focused.result.signal,
|
|
1383
|
+
stdoutTail: tailString(focused.result.stdout, tailBytes),
|
|
1384
|
+
stderrTail: tailString(focused.result.stderr, tailBytes),
|
|
1385
|
+
durationMs: focused.durationMs,
|
|
1386
|
+
failedTestFiles: files,
|
|
1387
|
+
failedFirst: report('failed'),
|
|
1388
|
+
scope: 'focused',
|
|
1389
|
+
ranFiles: [...pointer.files],
|
|
1390
|
+
});
|
|
1391
|
+
return {
|
|
1392
|
+
exitCode: entry.exitCode,
|
|
1393
|
+
signal: entry.signal ?? null,
|
|
1394
|
+
stdoutTail: entry.stdoutTail,
|
|
1395
|
+
stderrTail: entry.stderrTail,
|
|
1396
|
+
durationMs: entry.durationMs,
|
|
1397
|
+
cacheHit: false,
|
|
1398
|
+
timedOut: false,
|
|
1399
|
+
lockBusy: false,
|
|
1400
|
+
executionRoot,
|
|
1401
|
+
treeHash,
|
|
1402
|
+
harnessFailure: null,
|
|
1403
|
+
resourceKill: null,
|
|
1404
|
+
failedFirst: report('failed'),
|
|
1405
|
+
entry,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
default:
|
|
1409
|
+
failedFirst = report(focused.kind);
|
|
813
1410
|
}
|
|
814
1411
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1412
|
+
}
|
|
1413
|
+
// Spawn the tool ourselves.
|
|
1414
|
+
//
|
|
1415
|
+
// T12116: `test` and `build` run inside a transient systemd scope with
|
|
1416
|
+
// a hard `MemoryMax` and `MemorySwapMax=0`, so the kernel bounds the
|
|
1417
|
+
// ENTIRE process tree — a `pnpm -r` fan-out into fifteen packages is
|
|
1418
|
+
// still one cgroup, which is the multiplier the semaphore could not
|
|
1419
|
+
// see. Denying swap is the point: the failure this guards against was
|
|
1420
|
+
// a throttle-and-thrash host freeze, not an OOM kill. Degrades to an
|
|
1421
|
+
// unwrapped spawn off Linux or without a user systemd manager.
|
|
1422
|
+
const limited = withMemoryLimit(command.canonical, command.cmd, command.args, {
|
|
1423
|
+
// The scope name's rootHash8 comes off the execution root the tool
|
|
1424
|
+
// runs in (T12112 / gh#1220).
|
|
1425
|
+
executionRoot,
|
|
1426
|
+
});
|
|
1427
|
+
const spawnNormal = async () => {
|
|
830
1428
|
const startedAt = Date.now();
|
|
831
|
-
const result = await spawnCmd(limited.cmd, [...limited.args], executionRoot, spawnTimeoutMs,
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
//
|
|
870
|
-
// the WRAPPER is CLEO's own fault and not a verdict on the project's
|
|
871
|
-
// tool. Checked before the cache entry is built because, like the
|
|
872
|
-
// `timedOut` branch above, this run produced no result to cache — and
|
|
873
|
-
// caching one would serve a fabricated "your tests failed" from a
|
|
874
|
-
// key that cannot rotate until the tree changes.
|
|
875
|
-
// `limited.confined` OR the project's own pinned wrapper: post-detection
|
|
876
|
-
// (gh#1396) CLEO declines to wrap a command that is already
|
|
877
|
-
// `systemd-run`, so `confined` is false for exactly the project whose
|
|
878
|
-
// harness failures prompted gh#1397.
|
|
879
|
-
const harnessFailure = confinementStartupFailure(result.stderr, limited.confined || isSystemdRunCommand(command.cmd));
|
|
880
|
-
if (harnessFailure !== null) {
|
|
881
|
-
return {
|
|
882
|
-
exitCode: result.exitCode,
|
|
883
|
-
signal: result.signal,
|
|
884
|
-
stdoutTail: tailString(result.stdout, tailBytes),
|
|
885
|
-
stderrTail: tailString(result.stderr, tailBytes),
|
|
886
|
-
durationMs,
|
|
887
|
-
cacheHit: false,
|
|
888
|
-
timedOut: false,
|
|
889
|
-
lockBusy: false,
|
|
890
|
-
executionRoot,
|
|
891
|
-
harnessFailure,
|
|
892
|
-
entry: {
|
|
893
|
-
schemaVersion: 2,
|
|
894
|
-
key,
|
|
895
|
-
canonical: command.canonical,
|
|
896
|
-
displayName: command.displayName,
|
|
897
|
-
cmd: command.cmd,
|
|
898
|
-
args: command.args,
|
|
899
|
-
source: command.source,
|
|
900
|
-
head,
|
|
901
|
-
dirtyFingerprint,
|
|
902
|
-
executionRoot,
|
|
903
|
-
exitCode: null,
|
|
904
|
-
signal: result.signal,
|
|
905
|
-
stdoutTail: '',
|
|
906
|
-
stderrTail: tailString(result.stderr, tailBytes),
|
|
907
|
-
durationMs,
|
|
908
|
-
capturedAt: new Date().toISOString(),
|
|
909
|
-
},
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
const entry = {
|
|
913
|
-
schemaVersion: 2,
|
|
914
|
-
key,
|
|
915
|
-
canonical: command.canonical,
|
|
916
|
-
displayName: command.displayName,
|
|
917
|
-
cmd: command.cmd,
|
|
918
|
-
args: command.args,
|
|
919
|
-
source: command.source,
|
|
920
|
-
head,
|
|
921
|
-
dirtyFingerprint,
|
|
922
|
-
executionRoot,
|
|
923
|
-
exitCode: result.exitCode,
|
|
924
|
-
signal: result.signal,
|
|
925
|
-
stdoutTail: tailString(result.stdout, tailBytes),
|
|
926
|
-
stderrTail: tailString(result.stderr, tailBytes),
|
|
927
|
-
durationMs,
|
|
928
|
-
capturedAt: new Date().toISOString(),
|
|
929
|
-
};
|
|
930
|
-
// Persist only a usable entry. `isEntryUsable` is the SAME predicate
|
|
931
|
-
// `readCacheEntry` applies, which is the point: these two guards used
|
|
932
|
-
// to be independent transcriptions of one rule — `exitCode !== null
|
|
933
|
-
// && head !== null`, written out twice — so a field added to one had
|
|
934
|
-
// to be remembered into the other. gh#1380 and gh#1404 each edited
|
|
935
|
-
// both, and nothing checked that they still agreed.
|
|
936
|
-
//
|
|
937
|
-
// What the shared predicate refuses, and why none of it is an
|
|
938
|
-
// overcorrection:
|
|
939
|
-
// - unknown outcome (`exitCode: null`) — a signal kill, or a
|
|
940
|
-
// failure to start. The `timedOut` branch above already returns
|
|
941
|
-
// without writing for this reason; an OOM kill that is not a CLEO
|
|
942
|
-
// timeout (gh#1381) had no equivalent guard and fell through here.
|
|
943
|
-
// - unrotatable key (`head: null`) — costs a non-git CLEO root all
|
|
944
|
-
// caching. Stated rather than hidden: those projects are not
|
|
945
|
-
// getting valid caching today, they are getting ONE answer
|
|
946
|
-
// forever, and a slow correct answer beats a fast fabricated one.
|
|
947
|
-
// - unattributable run (`executionRoot` absent) — cannot occur on
|
|
948
|
-
// this path, and is required here anyway so that read and write
|
|
949
|
-
// stay ONE rule rather than two that happen to match today.
|
|
1429
|
+
const result = await spawnCmd(limited.cmd, [...limited.args], executionRoot, spawnTimeoutMs, toolEnv);
|
|
1430
|
+
return { result, durationMs: Date.now() - startedAt };
|
|
1431
|
+
};
|
|
1432
|
+
// gh#1397: `limited.confined` OR the project's own pinned wrapper:
|
|
1433
|
+
// post-detection (gh#1396) CLEO declines to wrap a command that is
|
|
1434
|
+
// already `systemd-run`, so `confined` is false for exactly the project
|
|
1435
|
+
// whose harness failures prompted gh#1397.
|
|
1436
|
+
const harnessOf = (r) => confinementStartupFailure(r.stderr, limited.confined || isSystemdRunCommand(command.cmd));
|
|
1437
|
+
const first = await spawnNormal();
|
|
1438
|
+
if (first.result.timedOut) {
|
|
1439
|
+
return timedOutResult(first.result, first.durationMs, failedFirst);
|
|
1440
|
+
}
|
|
1441
|
+
const firstHarness = harnessOf(first.result);
|
|
1442
|
+
if (firstHarness !== null) {
|
|
1443
|
+
return harnessResult(first.result, first.durationMs, firstHarness, failedFirst);
|
|
1444
|
+
}
|
|
1445
|
+
let { result, durationMs } = first;
|
|
1446
|
+
// T12989: a run killed for resources — a signal, exit 128 + a kill signal,
|
|
1447
|
+
// or a heap OOM the runner caught and turned into exit 1 — is not a verdict
|
|
1448
|
+
// on the code. It is reported, but not flake-retried (a full rerun at the
|
|
1449
|
+
// same limits under the same pressure doubles the cost of the kill), not
|
|
1450
|
+
// recorded for failed-first (the unknown outcome leaves the pointer) and
|
|
1451
|
+
// not cached (`isEntryUsable` refuses `resourceKill`).
|
|
1452
|
+
const resourceKill = resourceKillReason(result);
|
|
1453
|
+
let failedTestFiles;
|
|
1454
|
+
let flaky;
|
|
1455
|
+
let flakyFailureTail;
|
|
1456
|
+
if (failedFirstEnabled && result.exitCode !== null && resourceKill === null) {
|
|
1457
|
+
let files = result.exitCode === 0
|
|
1458
|
+
? []
|
|
1459
|
+
: parseFailingTestFiles(`${result.stdout}\n${result.stderr}`, executionRoot, tracked);
|
|
1460
|
+
// T12961 flake check (review of #1774): on a failure, re-run the FULL
|
|
1461
|
+
// recorded command once — never a narrower focused or per-package run,
|
|
1462
|
+
// which cannot see a `pnpm -r` bail, a coverage threshold, a non-vitest
|
|
1463
|
+
// step or a failure that only happens in the suite's context. A pass
|
|
1464
|
+
// on that identical rerun is a pass marked `flaky` with the first
|
|
1465
|
+
// run's failing files and failure tail; a second failure is red. A
|
|
1466
|
+
// rerun that times out, cannot start or is killed for resources decides
|
|
1467
|
+
// nothing, and the first failure stands.
|
|
950
1468
|
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1469
|
+
// Only a NARROW failure is retried: the failing files must be named and
|
|
1470
|
+
// number at most MAX_FLAKE_RETRY_FILES. A broken build or a mass
|
|
1471
|
+
// failure is deterministic, and doubling its cost buys nothing.
|
|
1472
|
+
if (result.exitCode !== 0 && files.length > 0 && files.length <= MAX_FLAKE_RETRY_FILES) {
|
|
1473
|
+
const retry = await spawnNormal();
|
|
1474
|
+
if (!retry.result.timedOut &&
|
|
1475
|
+
harnessOf(retry.result) === null &&
|
|
1476
|
+
resourceKillReason(retry.result) === null) {
|
|
1477
|
+
if (retry.result.exitCode === 0) {
|
|
1478
|
+
flaky = files.length > 0 ? files : ['<unknown>'];
|
|
1479
|
+
flakyFailureTail = tailString(`${result.stdout}\n${result.stderr}`, tailBytes);
|
|
1480
|
+
files = [];
|
|
1481
|
+
result = retry.result;
|
|
1482
|
+
durationMs += retry.durationMs;
|
|
1483
|
+
}
|
|
1484
|
+
else if (retry.result.exitCode !== null) {
|
|
1485
|
+
const again = parseFailingTestFiles(`${retry.result.stdout}\n${retry.result.stderr}`, executionRoot, tracked);
|
|
1486
|
+
if (again.length > 0)
|
|
1487
|
+
files = again;
|
|
1488
|
+
result = retry.result;
|
|
1489
|
+
durationMs += retry.durationMs;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
957
1492
|
}
|
|
1493
|
+
// Remember which test files failed, so the next run on a changed tree
|
|
1494
|
+
// can try them first. A pass, or a failure whose files cannot be
|
|
1495
|
+
// named, clears the pointer; an unknown outcome leaves it.
|
|
1496
|
+
if (files.length > 0)
|
|
1497
|
+
failedTestFiles = files;
|
|
1498
|
+
writeFailedFirstPointer(projectRoot, command.canonical, recordedRoot, files, treeHash);
|
|
1499
|
+
}
|
|
1500
|
+
const entry = makeEntry({
|
|
1501
|
+
exitCode: result.exitCode,
|
|
1502
|
+
signal: result.signal,
|
|
1503
|
+
stdoutTail: tailString(result.stdout, tailBytes),
|
|
1504
|
+
stderrTail: tailString(result.stderr, tailBytes),
|
|
1505
|
+
durationMs,
|
|
1506
|
+
...(resourceKill !== null ? { resourceKill } : {}),
|
|
1507
|
+
...(failedTestFiles ? { failedTestFiles } : {}),
|
|
1508
|
+
...(failedFirst ? { failedFirst } : {}),
|
|
1509
|
+
...(flaky && result.exitCode === 0 ? { flaky, flakyFailureTail } : {}),
|
|
1510
|
+
});
|
|
1511
|
+
// Persist only a usable entry. `isEntryUsable` is the SAME predicate
|
|
1512
|
+
// `readCacheEntry` applies, which is the point: these two guards used
|
|
1513
|
+
// to be independent transcriptions of one rule — `exitCode !== null
|
|
1514
|
+
// && head !== null`, written out twice — so a field added to one had
|
|
1515
|
+
// to be remembered into the other. gh#1380 and gh#1404 each edited
|
|
1516
|
+
// both, and nothing checked that they still agreed.
|
|
1517
|
+
//
|
|
1518
|
+
// What the shared predicate refuses, and why none of it is an
|
|
1519
|
+
// overcorrection:
|
|
1520
|
+
// - unknown outcome (`exitCode: null`) — a signal kill, or a
|
|
1521
|
+
// failure to start. The `timedOut` branch above already returns
|
|
1522
|
+
// without writing for this reason; an OOM kill that is not a CLEO
|
|
1523
|
+
// timeout (gh#1381) had no equivalent guard and fell through here.
|
|
1524
|
+
// - resource kill (`resourceKill`, T12989) — a REAL exit code that is
|
|
1525
|
+
// not a verdict: exit 137 from a wrapper, or vitest's exit 1 after a
|
|
1526
|
+
// worker's heap OOM. Cached, it replayed the 3 GB OOM to a retry
|
|
1527
|
+
// given 6 GB until the entry was deleted by hand.
|
|
1528
|
+
// - unrotatable key (`treeHash: null`) — costs a non-git CLEO root
|
|
1529
|
+
// all caching. Stated rather than hidden: those projects are not
|
|
1530
|
+
// getting valid caching today, they are getting ONE answer
|
|
1531
|
+
// forever, and a slow correct answer beats a fast fabricated one.
|
|
1532
|
+
//
|
|
1533
|
+
// Deliberately narrow in the other direction too: refusing, say, all
|
|
1534
|
+
// non-zero exits would trade a fabricated pass for a permanent cache
|
|
1535
|
+
// miss on healthy projects — the same overcorrection pointed the
|
|
1536
|
+
// other way.
|
|
1537
|
+
if (isEntryUsable(entry)) {
|
|
1538
|
+
writeCacheEntry(projectRoot, entry);
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
exitCode: entry.exitCode,
|
|
1542
|
+
signal: entry.signal ?? null,
|
|
1543
|
+
stdoutTail: entry.stdoutTail,
|
|
1544
|
+
stderrTail: entry.stderrTail,
|
|
1545
|
+
durationMs: entry.durationMs,
|
|
1546
|
+
cacheHit: false,
|
|
1547
|
+
timedOut: false,
|
|
1548
|
+
lockBusy: false,
|
|
1549
|
+
executionRoot,
|
|
1550
|
+
treeHash,
|
|
1551
|
+
harnessFailure: null,
|
|
1552
|
+
resourceKill,
|
|
1553
|
+
...(failedFirst ? { failedFirst } : {}),
|
|
1554
|
+
...(entry.flaky ? { flaky: entry.flaky } : {}),
|
|
1555
|
+
entry,
|
|
1556
|
+
};
|
|
1557
|
+
};
|
|
1558
|
+
// Coalescing (T12958): with a content key, N agents in N worktrees at the
|
|
1559
|
+
// same tree compute the SAME key, so the per-key lock is what turns N
|
|
1560
|
+
// identical suites into one. A caller that finds the lock held WAITS for
|
|
1561
|
+
// the holder's result instead of failing fast (T12025's ~0.7 s give-up
|
|
1562
|
+
// made every second caller report E_EVIDENCE_TOOL_BUSY and retry, i.e.
|
|
1563
|
+
// run the suite anyway). The wait happens OUTSIDE the global semaphore so a
|
|
1564
|
+
// waiter does not occupy a slot a different key could use.
|
|
1565
|
+
//
|
|
1566
|
+
// When the holder releases without a usable entry (timeout, harness
|
|
1567
|
+
// failure, resource kill, crash) the waiter loops and runs the tool itself. The total wait
|
|
1568
|
+
// is bounded by `lockWaitMs`; past it the caller gets `lockBusy`.
|
|
1569
|
+
// The holder's worst case is a focused rerun, the normal command and its
|
|
1570
|
+
// one flake retry — three spawn deadlines — plus slack.
|
|
1571
|
+
const lockWaitMs = opts.lockWaitMs ?? 3 * spawnTimeoutMs + 60_000;
|
|
1572
|
+
const lockPollMs = opts.lockPollMs ?? 1_000;
|
|
1573
|
+
const waitDeadline = Date.now() + lockWaitMs;
|
|
1574
|
+
// A bypassing caller must not accept the entry it chose to bypass.
|
|
1575
|
+
const acceptable = (e) => !bypassCache || Date.parse(e.capturedAt) >= callStartedAt;
|
|
1576
|
+
for (;;) {
|
|
1577
|
+
const releaseSemaphore = opts.skipGlobalSemaphore
|
|
1578
|
+
? undefined
|
|
1579
|
+
: await acquireGlobalSlot(command.canonical, opts.semaphoreOptions);
|
|
1580
|
+
try {
|
|
1581
|
+
return await withLock(cachePath, runLocked, { stale: lockStaleMs, retries: 3 });
|
|
1582
|
+
}
|
|
1583
|
+
catch (err) {
|
|
1584
|
+
// Only ELOCKED contention is waited out — permission errors and other
|
|
1585
|
+
// lock failures are re-thrown as real errors.
|
|
1586
|
+
const causeCode = err instanceof CleoError && err.code === ExitCode.LOCK_TIMEOUT
|
|
1587
|
+
? err.cause?.code
|
|
1588
|
+
: undefined;
|
|
1589
|
+
if (causeCode !== 'ELOCKED')
|
|
1590
|
+
throw err;
|
|
1591
|
+
}
|
|
1592
|
+
finally {
|
|
1593
|
+
if (releaseSemaphore)
|
|
1594
|
+
await releaseSemaphore();
|
|
1595
|
+
}
|
|
1596
|
+
// Another caller is running this exact key. Wait for its result.
|
|
1597
|
+
while (Date.now() < waitDeadline) {
|
|
1598
|
+
const done = readCacheEntry(projectRoot, key);
|
|
1599
|
+
if (done && acceptable(done))
|
|
1600
|
+
return hit(done);
|
|
1601
|
+
if (!(await isLocked(cachePath, { stale: lockStaleMs })))
|
|
1602
|
+
break;
|
|
1603
|
+
await new Promise((r) => setTimeout(r, Math.min(lockPollMs, waitDeadline - Date.now())));
|
|
1604
|
+
}
|
|
1605
|
+
const done = readCacheEntry(projectRoot, key);
|
|
1606
|
+
if (done && acceptable(done))
|
|
1607
|
+
return hit(done);
|
|
1608
|
+
if (Date.now() >= waitDeadline) {
|
|
1609
|
+
// T12025: a typed, actionable result so callers surface
|
|
1610
|
+
// E_EVIDENCE_TOOL_BUSY instead of a generic error.
|
|
958
1611
|
return {
|
|
959
|
-
exitCode:
|
|
960
|
-
signal:
|
|
961
|
-
stdoutTail:
|
|
962
|
-
stderrTail:
|
|
963
|
-
durationMs:
|
|
1612
|
+
exitCode: null,
|
|
1613
|
+
signal: null,
|
|
1614
|
+
stdoutTail: '',
|
|
1615
|
+
stderrTail: '',
|
|
1616
|
+
durationMs: 0,
|
|
964
1617
|
cacheHit: false,
|
|
965
1618
|
timedOut: false,
|
|
966
|
-
lockBusy:
|
|
967
|
-
executionRoot,
|
|
1619
|
+
lockBusy: true,
|
|
968
1620
|
harnessFailure: null,
|
|
969
|
-
|
|
1621
|
+
resourceKill: null,
|
|
1622
|
+
executionRoot,
|
|
1623
|
+
treeHash,
|
|
1624
|
+
entry: makeEntry({ exitCode: null, stdoutTail: '', stderrTail: '', durationMs: 0 }),
|
|
970
1625
|
};
|
|
971
|
-
}, { stale: lockStaleMs, retries: 3 });
|
|
972
|
-
}
|
|
973
|
-
catch (err) {
|
|
974
|
-
// T12025 (lock contention): a held cache lock causes ~47 s of blind
|
|
975
|
-
// proper-lockfile retries (50 × exponential backoff). Reduce to 3
|
|
976
|
-
// retries (~0.7 s fail-fast) and return a typed actionable result
|
|
977
|
-
// so callers surface E_EVIDENCE_TOOL_BUSY instead of a generic error.
|
|
978
|
-
// Only surface lockBusy for actual ELOCKED contention — permission
|
|
979
|
-
// errors and other lock failures are re-thrown as real errors.
|
|
980
|
-
if (err instanceof CleoError && err.code === ExitCode.LOCK_TIMEOUT) {
|
|
981
|
-
const causeCode = err.cause?.code;
|
|
982
|
-
if (causeCode === 'ELOCKED') {
|
|
983
|
-
return {
|
|
984
|
-
exitCode: null,
|
|
985
|
-
signal: null,
|
|
986
|
-
stdoutTail: '',
|
|
987
|
-
stderrTail: '',
|
|
988
|
-
durationMs: 0,
|
|
989
|
-
cacheHit: false,
|
|
990
|
-
timedOut: false,
|
|
991
|
-
lockBusy: true,
|
|
992
|
-
harnessFailure: null,
|
|
993
|
-
executionRoot,
|
|
994
|
-
entry: {
|
|
995
|
-
schemaVersion: 2,
|
|
996
|
-
key,
|
|
997
|
-
canonical: command.canonical,
|
|
998
|
-
displayName: command.displayName,
|
|
999
|
-
cmd: command.cmd,
|
|
1000
|
-
args: command.args,
|
|
1001
|
-
source: command.source,
|
|
1002
|
-
head,
|
|
1003
|
-
dirtyFingerprint,
|
|
1004
|
-
executionRoot,
|
|
1005
|
-
exitCode: null,
|
|
1006
|
-
stdoutTail: '',
|
|
1007
|
-
stderrTail: '',
|
|
1008
|
-
durationMs: 0,
|
|
1009
|
-
capturedAt: new Date().toISOString(),
|
|
1010
|
-
},
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
1626
|
}
|
|
1014
|
-
|
|
1015
|
-
}
|
|
1016
|
-
finally {
|
|
1017
|
-
if (releaseSemaphore)
|
|
1018
|
-
await releaseSemaphore();
|
|
1627
|
+
// The holder released without a usable result: run it ourselves.
|
|
1019
1628
|
}
|
|
1020
1629
|
}
|
|
1021
1630
|
function tailString(s, max) {
|