heyspex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fleet-queue-file.mjs — the file-backed (JSONL) queue implementation for a single machine /
|
|
4
|
+
* shared filesystem. THIS FILE WAS fleet-queue.mjs, moved here verbatim as part of the Neon
|
|
5
|
+
* fleet-queue port (HS-XOCK phase 4) — logic is unchanged from the original single-file
|
|
6
|
+
* module; only this header and the export surface note below are new.
|
|
7
|
+
*
|
|
8
|
+
* fleet-queue.mjs is now a thin backend-selecting façade (file vs. neon) that re-exports the
|
|
9
|
+
* same six functions as async wrappers — see fleet-queue.mjs and fleet-queue-neon.mjs. THIS
|
|
10
|
+
* module remains the offline / no-dispatcher fallback and the target of its own `--self-test`.
|
|
11
|
+
*
|
|
12
|
+
* Owner ask (2026-07-12): "If I have one agent already sending research request in progress
|
|
13
|
+
* to Night Action, and another agent has research that needs to be done, that agent can
|
|
14
|
+
* submit it successfully to the research queue - instead of getting a fail or rejection.
|
|
15
|
+
* This queue can also be used when Night Action has an existing ollama process. Research can
|
|
16
|
+
* be sent to the queue to be presented to Night Action as soon as it is done." And: "this
|
|
17
|
+
* queue concept can be used across the whole fleet for different use cases."
|
|
18
|
+
*
|
|
19
|
+
* FIRST LANE: "research-firecrawl" — research requests that lose the Ollama-XOR-Firecrawl
|
|
20
|
+
* mode-lease race in night-action-mode.mjs's requestOrEnqueueFirecrawlMode() land here
|
|
21
|
+
* instead of failing (see night-action-mode.mjs). Lanes are free-form strings; any future
|
|
22
|
+
* machine-gated resource (a build slot, a breakdown job, a GPU node) reuses this same module
|
|
23
|
+
* under its own lane name — see the design note at the bottom of this file.
|
|
24
|
+
*
|
|
25
|
+
* STORAGE — event-sourced, append-only JSONL: `.heyspex/fleet-queue.jsonl` (repo root,
|
|
26
|
+
* resolved by walking up to the heyspex.yaml vault marker — same convention as
|
|
27
|
+
* dev-factory/lib/fleet-state.mjs). Every mutation (enqueue / claim / complete / fail) is
|
|
28
|
+
* appended as ONE JSON line via a fresh open("a") + write + close (mirrors
|
|
29
|
+
* dev-factory/lib/run-events.mjs) — never a whole-file rewrite. Current item state is derived
|
|
30
|
+
* by FOLDING an item's events in file order, the same event-sourcing shape
|
|
31
|
+
* .heyspex/claim-history.jsonl and .heyspex/focus-history.jsonl already use elsewhere in this
|
|
32
|
+
* repo for durable ledgers (append pattern confirmed against both before writing this).
|
|
33
|
+
*
|
|
34
|
+
* WHY EVENT-SOURCED APPEND OVER REWRITE-WITH-STATUS (the crash-safety justification the task
|
|
35
|
+
* asked for): a whole-file rewrite (read all items, mutate one, write a temp file, rename over
|
|
36
|
+
* the original) is atomic PER WRITE but requires a read-modify-write on the FULL file for every
|
|
37
|
+
* single mutation, and a crash between "read" and "rename" either loses the mutation or (worse,
|
|
38
|
+
* on a partial rename) can corrupt every other item's state too. An append-only event log makes
|
|
39
|
+
* each mutation a single new line: a crash mid-write can only ever cost the LAST line (which
|
|
40
|
+
* readAllEvents() below already tolerates — a corrupt/partial trailing line is skipped, not
|
|
41
|
+
* thrown, mirroring run-events.mjs), and it can NEVER corrupt or lose any prior item's history.
|
|
42
|
+
* The trade is that readers must fold history instead of reading a snapshot — cheap here since
|
|
43
|
+
* the queue is small and local.
|
|
44
|
+
*
|
|
45
|
+
* CONCURRENCY — double-claim rejection: enqueue/complete/fail don't need cross-process
|
|
46
|
+
* exclusion (each only ever touches its own new line or an item only its own claimant should be
|
|
47
|
+
* mutating), but claim() has a genuine race — two processes could both read "pending" for the
|
|
48
|
+
* same id before either appends its claim event. claim() (and complete/fail, for symmetry) take
|
|
49
|
+
* a short-lived, per-id, TTL-bounded advisory file lock (`<queue>.lock.<id>`, exclusive-create
|
|
50
|
+
* `wx`) around their read-decide-append critical section — true mutual exclusion for same-id
|
|
51
|
+
* ops across processes, while different ids proceed fully in parallel. A lock file left behind
|
|
52
|
+
* by a crashed holder is auto-broken after `staleLockMs` (default 5s) so the queue can never
|
|
53
|
+
* wedge on a dead process.
|
|
54
|
+
*
|
|
55
|
+
* CRASH-RECOVERY (claim TTL): a claim() that never gets a matching complete()/fail() within
|
|
56
|
+
* `CLAIM_TTL_MS` (default 15 min, override via options or HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) is
|
|
57
|
+
* treated as STALE — effectiveStatus() reports it back as "pending" so any caller (including
|
|
58
|
+
* the original claimant retrying after a crash) can reclaim it. The stale claim's history is
|
|
59
|
+
* never deleted, only superseded by a fresh claim event — so `list()` still shows the full
|
|
60
|
+
* claim history for audit.
|
|
61
|
+
*
|
|
62
|
+
* API (all synchronous — the façade in fleet-queue.mjs wraps these as async for backend parity
|
|
63
|
+
* with fleet-queue-neon.mjs):
|
|
64
|
+
* enqueue({ lane, payload, requestedBy }) -> { id, position } // position = 1-indexed rank among pending items in that lane
|
|
65
|
+
* nextPending(lane) -> item | null // oldest pending (or stale-reclaimable) item in the lane
|
|
66
|
+
* claim(id, claimant) -> { ok, reason? } // rejects double-claim / terminal states
|
|
67
|
+
* complete(id, result?) -> { ok, reason? } // must currently be claimed
|
|
68
|
+
* fail(id, reason?) -> { ok, reason? } // from pending or claimed
|
|
69
|
+
* list(lane?, status?) -> item[] // oldest-first
|
|
70
|
+
*
|
|
71
|
+
* CLI: node dev-factory/lib/fleet-queue-file.mjs --self-test
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
|
|
75
|
+
import { dirname, join } from "node:path";
|
|
76
|
+
import { codeRepoRoot } from "./code-repo-root.mjs";
|
|
77
|
+
|
|
78
|
+
// ── repo-root + store resolution: this queue holds CODE-repo state (fleet-queue.jsonl), not
|
|
79
|
+
// vault state, so it anchors on the code repo itself rather than walking for a vault marker.
|
|
80
|
+
export function findRepoRoot() {
|
|
81
|
+
return codeRepoRoot();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function defaultQueuePath() {
|
|
85
|
+
const dir = join(findRepoRoot(), ".heyspex");
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
return process.env.HEYSPEX_FLEET_QUEUE_PATH || join(dir, "fleet-queue.jsonl");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const CLAIM_TTL_MS = Number(process.env.HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) || 15 * 60 * 1000; // 15 min
|
|
91
|
+
|
|
92
|
+
/** First lane (owner ask 2026-07-12): research requests that lose the Ollama-XOR-Firecrawl
|
|
93
|
+
* mode-lease race in night-action-mode.mjs land here instead of failing. */
|
|
94
|
+
export const RESEARCH_FIRECRAWL_LANE = "research-firecrawl";
|
|
95
|
+
|
|
96
|
+
// ── event log I/O (mirrors run-events.mjs: line-atomic append, corrupt-tail-tolerant read) ──────
|
|
97
|
+
function appendEvent(queuePath, record) {
|
|
98
|
+
mkdirSync(dirname(queuePath), { recursive: true });
|
|
99
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
100
|
+
const fd = openSync(queuePath, "a");
|
|
101
|
+
try {
|
|
102
|
+
writeSync(fd, line);
|
|
103
|
+
} finally {
|
|
104
|
+
closeSync(fd);
|
|
105
|
+
}
|
|
106
|
+
return record;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readAllEvents(queuePath) {
|
|
110
|
+
if (!existsSync(queuePath)) return [];
|
|
111
|
+
const text = readFileSync(queuePath, "utf8");
|
|
112
|
+
const events = [];
|
|
113
|
+
for (const line of text.split("\n")) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed) continue;
|
|
116
|
+
try {
|
|
117
|
+
events.push(JSON.parse(trimmed));
|
|
118
|
+
} catch {
|
|
119
|
+
// corrupt/partial trailing line (e.g. a crash mid-write) — skip, never throw
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return events;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── per-id advisory lock (exclusive-create; auto-breaks a stale/crashed holder) ─────────────────
|
|
126
|
+
function lockPathFor(queuePath, id) {
|
|
127
|
+
return `${queuePath}.lock.${id}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function acquireLock(queuePath, id, { timeoutMs = 3000, staleLockMs = 5000, pollMs = 15 } = {}) {
|
|
131
|
+
const lp = lockPathFor(queuePath, id);
|
|
132
|
+
mkdirSync(dirname(queuePath), { recursive: true });
|
|
133
|
+
const start = Date.now();
|
|
134
|
+
for (;;) {
|
|
135
|
+
try {
|
|
136
|
+
const fd = openSync(lp, "wx");
|
|
137
|
+
writeSync(fd, String(process.pid));
|
|
138
|
+
closeSync(fd);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e?.code !== "EEXIST") throw e;
|
|
142
|
+
try {
|
|
143
|
+
const st = statSync(lp);
|
|
144
|
+
if (Date.now() - st.mtimeMs > staleLockMs) {
|
|
145
|
+
rmSync(lp, { force: true }); // break a lock left by a crashed holder
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
continue; // lock vanished between the failed create and the stat — retry immediately
|
|
150
|
+
}
|
|
151
|
+
if (Date.now() - start > timeoutMs) return false;
|
|
152
|
+
// best-effort short busy-wait; this is a local dev tool, not a high-throughput queue
|
|
153
|
+
const until = Date.now() + pollMs;
|
|
154
|
+
while (Date.now() < until) { /* spin */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function releaseLock(queuePath, id) {
|
|
160
|
+
try {
|
|
161
|
+
rmSync(lockPathFor(queuePath, id), { force: true });
|
|
162
|
+
} catch {
|
|
163
|
+
// already gone — fine
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function withLock(queuePath, id, fn) {
|
|
168
|
+
if (!acquireLock(queuePath, id)) return { ok: false, reason: `fleet-queue: could not acquire lock for "${id}" (held too long)` };
|
|
169
|
+
try {
|
|
170
|
+
return fn();
|
|
171
|
+
} finally {
|
|
172
|
+
releaseLock(queuePath, id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── state derivation (event fold) ─────────────────────────────────────────────────────────────
|
|
177
|
+
function foldOne(events) {
|
|
178
|
+
let state = null;
|
|
179
|
+
for (const ev of events) {
|
|
180
|
+
if (ev.type === "enqueue") {
|
|
181
|
+
state = {
|
|
182
|
+
id: ev.id,
|
|
183
|
+
lane: ev.lane,
|
|
184
|
+
payload: ev.payload ?? null,
|
|
185
|
+
requestedBy: ev.requestedBy ?? null,
|
|
186
|
+
status: "pending",
|
|
187
|
+
enqueuedAt: ev.at,
|
|
188
|
+
claimant: null,
|
|
189
|
+
claimedAt: null,
|
|
190
|
+
result: null,
|
|
191
|
+
failReason: null,
|
|
192
|
+
completedAt: null,
|
|
193
|
+
};
|
|
194
|
+
} else if (!state) {
|
|
195
|
+
continue; // orphan event with no enqueue in this log slice — ignore
|
|
196
|
+
} else if (ev.type === "claim") {
|
|
197
|
+
state.status = "claimed";
|
|
198
|
+
state.claimant = ev.claimant ?? null;
|
|
199
|
+
state.claimedAt = ev.at;
|
|
200
|
+
} else if (ev.type === "complete") {
|
|
201
|
+
state.status = "completed";
|
|
202
|
+
state.result = ev.result ?? null;
|
|
203
|
+
state.completedAt = ev.at;
|
|
204
|
+
} else if (ev.type === "fail") {
|
|
205
|
+
state.status = "failed";
|
|
206
|
+
state.failReason = ev.reason ?? null;
|
|
207
|
+
state.completedAt = ev.at;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return state;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Raw folded state (claimed stays "claimed" even if stale) keyed by id, oldest enqueue first. */
|
|
214
|
+
function deriveRaw(queuePath) {
|
|
215
|
+
const events = readAllEvents(queuePath);
|
|
216
|
+
const byId = new Map();
|
|
217
|
+
for (const ev of events) {
|
|
218
|
+
if (!ev?.id) continue;
|
|
219
|
+
if (!byId.has(ev.id)) byId.set(ev.id, []);
|
|
220
|
+
byId.get(ev.id).push(ev);
|
|
221
|
+
}
|
|
222
|
+
const out = new Map();
|
|
223
|
+
for (const [id, evs] of byId) {
|
|
224
|
+
const s = foldOne(evs);
|
|
225
|
+
if (s) out.set(id, s);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Folded state with the TTL rule applied: a claim older than ttlMs reports back as "pending"
|
|
231
|
+
* (reclaimable) without erasing claimant/claimedAt history — callers can see it was stale. */
|
|
232
|
+
function deriveEffective(queuePath, { now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
233
|
+
const raw = deriveRaw(queuePath);
|
|
234
|
+
const out = new Map();
|
|
235
|
+
for (const [id, s] of raw) {
|
|
236
|
+
const stale = s.status === "claimed" && now - Date.parse(s.claimedAt) > ttlMs;
|
|
237
|
+
out.set(id, stale ? { ...s, status: "pending", stale: true } : { ...s, stale: false });
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function genId() {
|
|
243
|
+
return `fq_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── public API ────────────────────────────────────────────────────────────────────────────────
|
|
247
|
+
export function enqueue({ lane, payload = null, requestedBy = null, queuePath = defaultQueuePath(), now = Date.now() } = {}) {
|
|
248
|
+
if (!lane) throw new Error("fleet-queue.enqueue: lane is required");
|
|
249
|
+
const id = genId();
|
|
250
|
+
const at = new Date(now).toISOString();
|
|
251
|
+
appendEvent(queuePath, { type: "enqueue", id, lane, payload, requestedBy, at });
|
|
252
|
+
const items = [...deriveEffective(queuePath, { now }).values()]
|
|
253
|
+
.filter((s) => s.lane === lane && s.status === "pending")
|
|
254
|
+
.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
255
|
+
const position = items.findIndex((s) => s.id === id) + 1;
|
|
256
|
+
return { id, position };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function nextPending(lane, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
260
|
+
const items = [...deriveEffective(queuePath, { now, ttlMs }).values()]
|
|
261
|
+
.filter((s) => s.lane === lane && s.status === "pending")
|
|
262
|
+
.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
263
|
+
return items[0] ?? null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function claim(id, claimant, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
267
|
+
if (!id) return { ok: false, reason: "fleet-queue.claim: id is required" };
|
|
268
|
+
if (!claimant) return { ok: false, reason: "fleet-queue.claim: claimant is required" };
|
|
269
|
+
return withLock(queuePath, id, () => {
|
|
270
|
+
const state = deriveEffective(queuePath, { now, ttlMs }).get(id);
|
|
271
|
+
if (!state) return { ok: false, reason: `unknown id "${id}"` };
|
|
272
|
+
if (state.status === "completed") return { ok: false, reason: "already completed" };
|
|
273
|
+
if (state.status === "failed") return { ok: false, reason: "already failed" };
|
|
274
|
+
if (state.status === "claimed") return { ok: false, reason: `already claimed by ${state.claimant}` };
|
|
275
|
+
appendEvent(queuePath, { type: "claim", id, claimant, at: new Date(now).toISOString() });
|
|
276
|
+
return { ok: true, id, claimant, reclaimed: Boolean(state.stale) };
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function complete(id, result = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
|
|
281
|
+
if (!id) return { ok: false, reason: "fleet-queue.complete: id is required" };
|
|
282
|
+
return withLock(queuePath, id, () => {
|
|
283
|
+
const state = deriveEffective(queuePath, { now }).get(id);
|
|
284
|
+
if (!state) return { ok: false, reason: `unknown id "${id}"` };
|
|
285
|
+
if (state.status !== "claimed") return { ok: false, reason: `cannot complete from status "${state.status}" (must be claimed)` };
|
|
286
|
+
appendEvent(queuePath, { type: "complete", id, result, at: new Date(now).toISOString() });
|
|
287
|
+
return { ok: true, id };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function fail(id, reason = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
|
|
292
|
+
if (!id) return { ok: false, reason: "fleet-queue.fail: id is required" };
|
|
293
|
+
return withLock(queuePath, id, () => {
|
|
294
|
+
const state = deriveEffective(queuePath, { now }).get(id);
|
|
295
|
+
if (!state) return { ok: false, reason: `unknown id "${id}"` };
|
|
296
|
+
if (state.status === "completed" || state.status === "failed") {
|
|
297
|
+
return { ok: false, reason: `cannot fail from terminal status "${state.status}"` };
|
|
298
|
+
}
|
|
299
|
+
appendEvent(queuePath, { type: "fail", id, reason, at: new Date(now).toISOString() });
|
|
300
|
+
return { ok: true, id };
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function list(lane = null, status = null, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
305
|
+
const items = [...deriveEffective(queuePath, { now, ttlMs }).values()];
|
|
306
|
+
return items
|
|
307
|
+
.filter((s) => (lane ? s.lane === lane : true))
|
|
308
|
+
.filter((s) => (status ? s.status === status : true))
|
|
309
|
+
.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ═══ RESOURCE GOVERNOR (HS-MUJ6) — implementation ══════════════════════════════════════════════
|
|
313
|
+
// See the design-note block below for the full rationale, the stated liveness rule, and the
|
|
314
|
+
// configuration contract. Everything in this section is additive; the queue API above is untouched.
|
|
315
|
+
|
|
316
|
+
/** Governor tunables — DEFAULTS only. Never read these directly for behavior; go through
|
|
317
|
+
* resolveGovernorConfig() so explicit overrides and HEYSPEX_GOVERNOR_* env vars apply
|
|
318
|
+
* ([[HS-DEC-dev-factory-rules-are-configuration-not]]: every rule is a setting, never code). */
|
|
319
|
+
export const GOVERNOR_DEFAULTS = Object.freeze({
|
|
320
|
+
/** Liveness rule input: a non-paused holder whose last liveness signal (grant, heartbeat,
|
|
321
|
+
* resume, or idempotent re-request) is older than this is DEAD and reclaimable.
|
|
322
|
+
* Default matches the queue's CLAIM_TTL_MS convention (15 min). */
|
|
323
|
+
leaseTtlMs: 15 * 60 * 1000,
|
|
324
|
+
/** Blocking acquire() poll interval — how quickly a waiter observes its wake-on-release. */
|
|
325
|
+
wakePollMs: 250,
|
|
326
|
+
/** Blocking acquire() gives up (and withdraws from the FIFO line) after this long. */
|
|
327
|
+
waitTimeoutMs: 10 * 60 * 1000,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const GOVERNOR_ENV_VARS = Object.freeze({
|
|
331
|
+
leaseTtlMs: "HEYSPEX_GOVERNOR_LEASE_TTL_MS",
|
|
332
|
+
wakePollMs: "HEYSPEX_GOVERNOR_WAKE_POLL_MS",
|
|
333
|
+
waitTimeoutMs: "HEYSPEX_GOVERNOR_WAIT_TIMEOUT_MS",
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/** Layered config resolution: explicit overrides > HEYSPEX_GOVERNOR_* env > GOVERNOR_DEFAULTS.
|
|
337
|
+
* Env is read at CALL time (not module load) so a process can be re-configured without a
|
|
338
|
+
* restart and tests can exercise the env layer deterministically. */
|
|
339
|
+
export function resolveGovernorConfig(overrides = {}) {
|
|
340
|
+
const out = {};
|
|
341
|
+
for (const key of Object.keys(GOVERNOR_DEFAULTS)) {
|
|
342
|
+
const explicit = overrides?.[key];
|
|
343
|
+
if (explicit !== undefined && explicit !== null && Number.isFinite(Number(explicit))) {
|
|
344
|
+
out[key] = Number(explicit);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const envRaw = process.env[GOVERNOR_ENV_VARS[key]];
|
|
348
|
+
const envNum = envRaw != null && String(envRaw).trim() !== "" ? Number(envRaw) : NaN;
|
|
349
|
+
out[key] = Number.isFinite(envNum) ? envNum : GOVERNOR_DEFAULTS[key];
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** THE stated liveness rule, in one place — stamped into every gov-reclaimed event so the audit
|
|
355
|
+
* trail records not just THAT a lease was taken back, but under exactly which rule. */
|
|
356
|
+
export function describeLivenessRule({ leaseTtlMs } = resolveGovernorConfig()) {
|
|
357
|
+
return (
|
|
358
|
+
`a holder is DEAD (lease reclaimable) iff it is NOT paused AND now - lastLivenessAt > ` +
|
|
359
|
+
`leaseTtlMs (${leaseTtlMs}ms); a PAUSED holder is never reclaimed by liveness — only ` +
|
|
360
|
+
`resume() or an administrator forceBreak() ends a pause`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── governor stores (the engine is store-agnostic; these two ship here) ────────────────────────
|
|
365
|
+
|
|
366
|
+
/** Pure in-process store — for tests, and for the console dispatcher to mount server-side. */
|
|
367
|
+
export function createMemoryGovernorStore() {
|
|
368
|
+
const byResource = new Map(); // resource -> event[]
|
|
369
|
+
return {
|
|
370
|
+
read(resource) {
|
|
371
|
+
return [...(byResource.get(resource) ?? [])];
|
|
372
|
+
},
|
|
373
|
+
append(record) {
|
|
374
|
+
if (!byResource.has(record.resource)) byResource.set(record.resource, []);
|
|
375
|
+
byResource.get(record.resource).push(record);
|
|
376
|
+
},
|
|
377
|
+
withLock(_resource, fn) {
|
|
378
|
+
return fn(); // single-process, synchronous — no cross-process exclusion needed
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function defaultGovernorPath() {
|
|
384
|
+
const dir = join(findRepoRoot(), ".heyspex");
|
|
385
|
+
mkdirSync(dir, { recursive: true });
|
|
386
|
+
return process.env.HEYSPEX_RESOURCE_GOVERNOR_PATH || join(dir, "resource-governor.jsonl");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Resource keys ("git:repo") appear in lock FILENAMES — ':' is illegal on Windows, so the lock
|
|
390
|
+
* key is sanitized. The event log itself keeps the exact resource string. */
|
|
391
|
+
function sanitizeLockKey(resource) {
|
|
392
|
+
return String(resource).replace(/[^A-Za-z0-9._-]/g, "-");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Durable local store: append-only JSONL + the same per-key advisory lock the queue's claim()
|
|
396
|
+
* uses — true cross-process mutual exclusion per resource on one filesystem. */
|
|
397
|
+
export function createFileGovernorStore({ governorPath = defaultGovernorPath() } = {}) {
|
|
398
|
+
return {
|
|
399
|
+
read(resource) {
|
|
400
|
+
return readAllEvents(governorPath).filter((ev) => ev?.resource === resource);
|
|
401
|
+
},
|
|
402
|
+
append(record) {
|
|
403
|
+
appendEvent(governorPath, record);
|
|
404
|
+
},
|
|
405
|
+
withLock(resource, fn) {
|
|
406
|
+
const key = `gov.${sanitizeLockKey(resource)}`;
|
|
407
|
+
if (!acquireLock(governorPath, key)) {
|
|
408
|
+
return { ok: false, reason: `resource-governor: could not acquire lock for "${resource}" (held too long)` };
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
return fn();
|
|
412
|
+
} finally {
|
|
413
|
+
releaseLock(governorPath, key);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── governor state derivation (event fold — same event-sourcing shape as the queue) ────────────
|
|
420
|
+
|
|
421
|
+
/** Fold one resource's governor events (in log order) into { resource, holder, waiters }.
|
|
422
|
+
* holder: { holderId, leaseId, grantedAt, livenessAt, paused } | null.
|
|
423
|
+
* waiters: FIFO-ordered [{ holderId, requestId, enqueuedAt }]. Exported for the console
|
|
424
|
+
* dispatcher's server-side wiring (later task) to reuse verbatim. */
|
|
425
|
+
export function foldGovernorResource(events, resource) {
|
|
426
|
+
const st = { resource, holder: null, waiters: [] };
|
|
427
|
+
for (const ev of events) {
|
|
428
|
+
if (!ev || ev.resource !== resource) continue;
|
|
429
|
+
switch (ev.type) {
|
|
430
|
+
case "gov-granted":
|
|
431
|
+
st.holder = { holderId: ev.holderId, leaseId: ev.leaseId ?? null, grantedAt: ev.at, livenessAt: ev.at, paused: false };
|
|
432
|
+
st.waiters = st.waiters.filter((w) => w.holderId !== ev.holderId);
|
|
433
|
+
break;
|
|
434
|
+
case "gov-waiting":
|
|
435
|
+
if (st.holder?.holderId !== ev.holderId && !st.waiters.some((w) => w.holderId === ev.holderId)) {
|
|
436
|
+
st.waiters.push({ holderId: ev.holderId, requestId: ev.requestId ?? null, enqueuedAt: ev.at });
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
case "gov-heartbeat":
|
|
440
|
+
if (st.holder?.holderId === ev.holderId) st.holder.livenessAt = ev.at;
|
|
441
|
+
break;
|
|
442
|
+
case "gov-paused":
|
|
443
|
+
if (st.holder?.holderId === ev.holderId) st.holder.paused = true;
|
|
444
|
+
break;
|
|
445
|
+
case "gov-resumed":
|
|
446
|
+
if (st.holder?.holderId === ev.holderId) {
|
|
447
|
+
st.holder.paused = false;
|
|
448
|
+
st.holder.livenessAt = ev.at; // resuming is itself a liveness signal
|
|
449
|
+
}
|
|
450
|
+
break;
|
|
451
|
+
case "gov-released":
|
|
452
|
+
case "gov-reclaimed":
|
|
453
|
+
case "gov-broken":
|
|
454
|
+
if (st.holder?.holderId === ev.holderId) st.holder = null;
|
|
455
|
+
break;
|
|
456
|
+
case "gov-abandoned":
|
|
457
|
+
st.waiters = st.waiters.filter((w) => w.requestId !== ev.requestId);
|
|
458
|
+
break;
|
|
459
|
+
default:
|
|
460
|
+
break; // unknown event kinds are ignored, never fatal (forward compatibility)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return st;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function governorGenId() {
|
|
467
|
+
return `lease_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const defaultGovernorSleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
471
|
+
|
|
472
|
+
// ── the governor engine ────────────────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* createResourceGovernor({ store, now, genId, config }) -> governor
|
|
476
|
+
*
|
|
477
|
+
* Pure-logic engine over an injected event store. All mutations are read-decide-append critical
|
|
478
|
+
* sections under store.withLock(resource) — the same atomic-claim discipline as claim() above.
|
|
479
|
+
*
|
|
480
|
+
* request(resource, holderId) -> { ok, granted:true, leaseId, alreadyHeld }
|
|
481
|
+
* | { ok, granted:false, position, holder }
|
|
482
|
+
* Non-blocking. Grants when free (or when this holder is the FIFO head after a release/
|
|
483
|
+
* reclaim); otherwise JOINS the FIFO line — never a refusal. Idempotent for the current
|
|
484
|
+
* holder (re-request refreshes liveness: "held across multiple operations").
|
|
485
|
+
* Runs dead-holder reclaim first, so any poller drives progress.
|
|
486
|
+
* acquire(resource, holderId, { timeoutMs, pollMs, sleep }) -> Promise<same shapes>
|
|
487
|
+
* Blocking convenience: request() then poll until granted or timeout (then withdraws
|
|
488
|
+
* from the line and returns { ok:false, granted:false, reason }).
|
|
489
|
+
* heartbeat(resource, holderId) -> { ok } — refresh liveness while holding.
|
|
490
|
+
* pause(resource, holderId) / resume(...) -> { ok } — "alive but going quiet"; a paused
|
|
491
|
+
* holder is exempt from liveness reclaim until resume()/forceBreak().
|
|
492
|
+
* release(resource, holderId) -> { ok, grantedTo } — explicit hand-back; the
|
|
493
|
+
* FIFO head is granted in the same critical section (wake-on-release).
|
|
494
|
+
* forceBreak(resource, { by, reason }) -> { ok, broken, grantedTo } — administrator's
|
|
495
|
+
* escape hatch; `by` is REQUIRED so every break is attributably recorded.
|
|
496
|
+
* withdraw(resource, holderId) -> { ok } — leave the FIFO line voluntarily.
|
|
497
|
+
* status(resource) -> read-only { resource, holder(+stale), waiters }.
|
|
498
|
+
*/
|
|
499
|
+
export function createResourceGovernor({
|
|
500
|
+
store = createMemoryGovernorStore(),
|
|
501
|
+
now = Date.now,
|
|
502
|
+
genId = governorGenId,
|
|
503
|
+
config = {},
|
|
504
|
+
} = {}) {
|
|
505
|
+
const cfg = resolveGovernorConfig(config);
|
|
506
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
507
|
+
const fold = (resource) => foldGovernorResource(store.read(resource), resource);
|
|
508
|
+
|
|
509
|
+
/** The liveness rule, applied. Kept next to its documentation on purpose. */
|
|
510
|
+
function isDead(holder, nowMs) {
|
|
511
|
+
if (!holder) return false;
|
|
512
|
+
if (holder.paused) return false; // a live-but-paused holder is NEVER reclaimed
|
|
513
|
+
return nowMs - Date.parse(holder.livenessAt) > cfg.leaseTtlMs;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function grant(st, holderId, nowMs) {
|
|
517
|
+
const leaseId = genId();
|
|
518
|
+
store.append({ type: "gov-granted", resource: st.resource, holderId, leaseId, at: iso(nowMs) });
|
|
519
|
+
st.holder = { holderId, leaseId, grantedAt: iso(nowMs), livenessAt: iso(nowMs), paused: false };
|
|
520
|
+
st.waiters = st.waiters.filter((w) => w.holderId !== holderId);
|
|
521
|
+
return leaseId;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Reclaim a dead holder (recording the rule it died under), then hand a free resource to the
|
|
525
|
+
* FIFO head. Called inside every mutating critical section. */
|
|
526
|
+
function reclaimAndPromote(st, nowMs) {
|
|
527
|
+
if (st.holder && isDead(st.holder, nowMs)) {
|
|
528
|
+
store.append({
|
|
529
|
+
type: "gov-reclaimed",
|
|
530
|
+
resource: st.resource,
|
|
531
|
+
holderId: st.holder.holderId,
|
|
532
|
+
leaseId: st.holder.leaseId,
|
|
533
|
+
rule: describeLivenessRule(cfg),
|
|
534
|
+
at: iso(nowMs),
|
|
535
|
+
});
|
|
536
|
+
st.holder = null;
|
|
537
|
+
}
|
|
538
|
+
if (!st.holder && st.waiters.length > 0) {
|
|
539
|
+
const head = st.waiters.shift();
|
|
540
|
+
grant(st, head.holderId, nowMs);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function request(resource, holderId) {
|
|
545
|
+
if (!resource) return { ok: false, reason: "resource-governor.request: resource is required" };
|
|
546
|
+
if (!holderId) return { ok: false, reason: "resource-governor.request: holderId is required" };
|
|
547
|
+
return store.withLock(resource, () => {
|
|
548
|
+
const nowMs = now();
|
|
549
|
+
const st = fold(resource);
|
|
550
|
+
const wasHolder = st.holder?.holderId === holderId;
|
|
551
|
+
reclaimAndPromote(st, nowMs);
|
|
552
|
+
if (st.holder?.holderId === holderId) {
|
|
553
|
+
// already held, or just promoted from the FIFO head — either way, refresh liveness
|
|
554
|
+
store.append({ type: "gov-heartbeat", resource, holderId, at: iso(nowMs) });
|
|
555
|
+
return { ok: true, granted: true, resource, holderId, leaseId: st.holder.leaseId, alreadyHeld: wasHolder };
|
|
556
|
+
}
|
|
557
|
+
if (!st.holder) {
|
|
558
|
+
// free with nobody ahead (promotion above would have taken the head) — grant directly
|
|
559
|
+
const leaseId = grant(st, holderId, nowMs);
|
|
560
|
+
return { ok: true, granted: true, resource, holderId, leaseId, alreadyHeld: false };
|
|
561
|
+
}
|
|
562
|
+
// held by someone else (live or paused) — wait in the FIFO line, never refuse
|
|
563
|
+
let pos = st.waiters.findIndex((w) => w.holderId === holderId);
|
|
564
|
+
if (pos === -1) {
|
|
565
|
+
store.append({ type: "gov-waiting", resource, holderId, requestId: genId(), at: iso(nowMs) });
|
|
566
|
+
pos = st.waiters.length; // 0-indexed slot of the new tail
|
|
567
|
+
}
|
|
568
|
+
return { ok: true, granted: false, resource, holderId, position: pos + 1, holder: st.holder.holderId };
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function acquire(resource, holderId, { timeoutMs = cfg.waitTimeoutMs, pollMs = cfg.wakePollMs, sleep = defaultGovernorSleep } = {}) {
|
|
573
|
+
const deadline = now() + timeoutMs;
|
|
574
|
+
for (;;) {
|
|
575
|
+
const r = request(resource, holderId);
|
|
576
|
+
if (!r.ok || r.granted) return r;
|
|
577
|
+
if (now() >= deadline) {
|
|
578
|
+
withdraw(resource, holderId); // leave the line — never linger as a ghost waiter
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
granted: false,
|
|
582
|
+
resource,
|
|
583
|
+
holderId,
|
|
584
|
+
position: r.position,
|
|
585
|
+
reason: `resource-governor.acquire: timed out after ${timeoutMs}ms waiting for "${resource}" (held by ${r.holder}, position ${r.position})`,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
await sleep(pollMs);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function heartbeat(resource, holderId) {
|
|
593
|
+
return store.withLock(resource, () => {
|
|
594
|
+
const nowMs = now();
|
|
595
|
+
const st = fold(resource);
|
|
596
|
+
if (st.holder?.holderId !== holderId) {
|
|
597
|
+
return { ok: false, reason: `resource-governor.heartbeat: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
|
|
598
|
+
}
|
|
599
|
+
store.append({ type: "gov-heartbeat", resource, holderId, at: iso(nowMs) });
|
|
600
|
+
return { ok: true, resource, holderId, livenessAt: iso(nowMs) };
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function pause(resource, holderId) {
|
|
605
|
+
return store.withLock(resource, () => {
|
|
606
|
+
const nowMs = now();
|
|
607
|
+
const st = fold(resource);
|
|
608
|
+
if (st.holder?.holderId !== holderId) {
|
|
609
|
+
return { ok: false, reason: `resource-governor.pause: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
|
|
610
|
+
}
|
|
611
|
+
store.append({ type: "gov-paused", resource, holderId, at: iso(nowMs) });
|
|
612
|
+
return { ok: true, resource, holderId, paused: true };
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function resume(resource, holderId) {
|
|
617
|
+
return store.withLock(resource, () => {
|
|
618
|
+
const nowMs = now();
|
|
619
|
+
const st = fold(resource);
|
|
620
|
+
if (st.holder?.holderId !== holderId) {
|
|
621
|
+
return { ok: false, reason: `resource-governor.resume: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
|
|
622
|
+
}
|
|
623
|
+
store.append({ type: "gov-resumed", resource, holderId, at: iso(nowMs) });
|
|
624
|
+
return { ok: true, resource, holderId, paused: false };
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function release(resource, holderId) {
|
|
629
|
+
return store.withLock(resource, () => {
|
|
630
|
+
const nowMs = now();
|
|
631
|
+
const st = fold(resource);
|
|
632
|
+
if (st.holder?.holderId !== holderId) {
|
|
633
|
+
return { ok: false, reason: `resource-governor.release: "${holderId}" does not hold "${resource}" (holder: ${st.holder?.holderId ?? "none"})` };
|
|
634
|
+
}
|
|
635
|
+
store.append({ type: "gov-released", resource, holderId, at: iso(nowMs) });
|
|
636
|
+
st.holder = null;
|
|
637
|
+
reclaimAndPromote(st, nowMs); // wake-on-release: FIFO head granted in this same critical section
|
|
638
|
+
return { ok: true, resource, released: holderId, grantedTo: st.holder?.holderId ?? null };
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function forceBreak(resource, { by, reason = null } = {}) {
|
|
643
|
+
if (!by) {
|
|
644
|
+
return { ok: false, reason: "resource-governor.forceBreak: `by` (the administrator identity) is required — every break is recorded" };
|
|
645
|
+
}
|
|
646
|
+
return store.withLock(resource, () => {
|
|
647
|
+
const nowMs = now();
|
|
648
|
+
const st = fold(resource);
|
|
649
|
+
const broken = st.holder?.holderId ?? null;
|
|
650
|
+
if (st.holder) {
|
|
651
|
+
store.append({ type: "gov-broken", resource, holderId: st.holder.holderId, leaseId: st.holder.leaseId, by, reason, at: iso(nowMs) });
|
|
652
|
+
st.holder = null;
|
|
653
|
+
}
|
|
654
|
+
reclaimAndPromote(st, nowMs);
|
|
655
|
+
return { ok: true, resource, broken, by, grantedTo: st.holder?.holderId ?? null };
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function withdraw(resource, holderId) {
|
|
660
|
+
return store.withLock(resource, () => {
|
|
661
|
+
const st = fold(resource);
|
|
662
|
+
const w = st.waiters.find((x) => x.holderId === holderId);
|
|
663
|
+
if (!w) return { ok: false, reason: `resource-governor.withdraw: "${holderId}" is not waiting for "${resource}"` };
|
|
664
|
+
store.append({ type: "gov-abandoned", resource, holderId, requestId: w.requestId, at: iso(now()) });
|
|
665
|
+
return { ok: true, resource, holderId };
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Read-only snapshot — never mutates (a dead holder is REPORTED stale here; reclaim happens
|
|
670
|
+
* on the next mutating call, mirroring deriveEffective()'s non-destructive stale reporting). */
|
|
671
|
+
function status(resource) {
|
|
672
|
+
const nowMs = now();
|
|
673
|
+
const st = fold(resource);
|
|
674
|
+
return {
|
|
675
|
+
resource,
|
|
676
|
+
holder: st.holder ? { ...st.holder, stale: isDead(st.holder, nowMs) } : null,
|
|
677
|
+
waiters: st.waiters.map((w, i) => ({ ...w, position: i + 1 })),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return { config: cfg, request, acquire, heartbeat, pause, resume, release, forceBreak, withdraw, status };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/*
|
|
685
|
+
* ── RESOURCE GOVERNOR (HS-MUJ6, [[HS-AU7I-2U3R-EPIC-resource-governor-leases-and-wait]]) ───────
|
|
686
|
+
*
|
|
687
|
+
* The queue above is a WORK queue: a claim is per-task and terminal (complete/fail are the only
|
|
688
|
+
* exits). The governor below adds the missing WAIT-queue semantics for named, non-forkable
|
|
689
|
+
* resources ("git:repo", "service:spex-console", "machine:specops-gpu0", "mode:night-action", …):
|
|
690
|
+
*
|
|
691
|
+
* one holder · a lease with liveness · a FIFO waiting line · an explicit release ·
|
|
692
|
+
* an administrator's forced-break.
|
|
693
|
+
*
|
|
694
|
+
* It REUSES the atomic-claim discipline already proven above rather than rewriting it: every
|
|
695
|
+
* mutation is a read-decide-append critical section under the same per-key advisory lock
|
|
696
|
+
* (acquireLock/releaseLock), persisted to the same append-only, corrupt-tail-tolerant JSONL shape
|
|
697
|
+
* (appendEvent/readAllEvents) in its own ledger, `.heyspex/resource-governor.jsonl`
|
|
698
|
+
* (override: HEYSPEX_RESOURCE_GOVERNOR_PATH). The ledger doubles as the audit trail — a
|
|
699
|
+
* forced-break or a reclaim is an event line naming who/why/when, never an erased row.
|
|
700
|
+
*
|
|
701
|
+
* THE LIVENESS RULE (explicit, and stamped into every gov-reclaimed event):
|
|
702
|
+
* a holder is DEAD (its lease reclaimable) iff it is NOT paused AND
|
|
703
|
+
* now − lastLivenessAt > leaseTtlMs.
|
|
704
|
+
* A PAUSED holder is NEVER reclaimed by liveness, no matter how old its last heartbeat —
|
|
705
|
+
* pause() is the holder saying "alive, but about to go quiet" (a long tool call, lunch).
|
|
706
|
+
* Only resume() or an administrator's forceBreak() ends a pause. forceBreak is the
|
|
707
|
+
* deliberately-shipped-on-day-one escape hatch for the holder who paused and went home.
|
|
708
|
+
*
|
|
709
|
+
* CONFIGURATION, NOT CONSTANTS ([[HS-DEC-dev-factory-rules-are-configuration-not]]): every
|
|
710
|
+
* tunable is a config parameter with a default — resolveGovernorConfig() layers
|
|
711
|
+
* explicit overrides > HEYSPEX_GOVERNOR_* env vars > GOVERNOR_DEFAULTS. Nothing here is a bare
|
|
712
|
+
* constant an owner cannot change without editing code.
|
|
713
|
+
*
|
|
714
|
+
* PURITY / TESTABILITY: createResourceGovernor() takes an injectable store, clock (`now`),
|
|
715
|
+
* id generator (`genId`), and — for the blocking acquire() — an injectable `sleep`, so every
|
|
716
|
+
* semantic (FIFO order, reclaim-vs-paused, wake-on-release) is testable with a fake clock and
|
|
717
|
+
* no real waiting. Two stores ship here: createMemoryGovernorStore() (pure, in-process — also
|
|
718
|
+
* what the console dispatcher will mount server-side) and createFileGovernorStore() (this
|
|
719
|
+
* machine's durable JSONL mirror; cross-process on one filesystem via the advisory lock).
|
|
720
|
+
*
|
|
721
|
+
* WAKE-ON-RELEASE: release()/forceBreak() promote the FIFO head to holder IN THE SAME critical
|
|
722
|
+
* section — the next waiter is never left un-woken for a drain nobody triggers. In-process
|
|
723
|
+
* blocking waiters (acquire()) observe the grant on their next poll tick (wakePollMs); a waiter
|
|
724
|
+
* that crashed while holding its granted lease is itself reclaimed by the liveness rule, so a
|
|
725
|
+
* dead waiter can never wedge the line. Dead-holder reclaim also runs at the top of every
|
|
726
|
+
* request(), so any poller drives progress even when the holder died silently.
|
|
727
|
+
*
|
|
728
|
+
* ── DESIGN NOTE: how this generalizes fleet-wide, and the multi-machine port ───────────────────
|
|
729
|
+
*
|
|
730
|
+
* This module is deliberately domain-blind: `lane` is a free-form string, `payload` is opaque
|
|
731
|
+
* JSON, and nothing in enqueue/claim/complete/fail/list knows about research, Firecrawl, or
|
|
732
|
+
* Night Action. The first lane ("research-firecrawl") is a CONSUMER of this queue, not part of
|
|
733
|
+
* its design. Any other machine-gated resource in the fleet can stand up its own lane the same
|
|
734
|
+
* way, with zero changes to this file:
|
|
735
|
+
* - build jobs waiting on a busy GPU box -> lane "build-<machine>"
|
|
736
|
+
* - breakdown/critic jobs waiting on the DeepSeek seat -> lane "breakdown-deepseek"
|
|
737
|
+
* - any single-tenant resource with a "one workload at a time" mode lease, mirroring
|
|
738
|
+
* night-action-mode.mjs's Ollama-XOR-Firecrawl shape (mode lease refuses -> caller enqueues
|
|
739
|
+
* instead of failing -> a drain step pops the lane once the resource frees up)
|
|
740
|
+
* The pattern each consumer follows is the same three-step shape night-action-mode.mjs
|
|
741
|
+
* demonstrates: (1) try the gated resource directly, (2) on refusal, enqueue() instead of
|
|
742
|
+
* returning a hard failure, (3) a --drain (or equivalent poll) on the resource-owning side pops
|
|
743
|
+
* nextPending()/list() once the resource is free and dispatches the work.
|
|
744
|
+
*
|
|
745
|
+
* MULTI-MACHINE PORT (done, HS-XOCK phase 4): this file-backed JSONL queue is correct for a
|
|
746
|
+
* SINGLE shared filesystem (one repo checkout, or machines that all see the same network share)
|
|
747
|
+
* — the per-id file lock only provides mutual exclusion within one filesystem. Machines with no
|
|
748
|
+
* shared disk now go through fleet-queue-neon.mjs instead — a Neon control-plane HTTP client
|
|
749
|
+
* against the console's bearer-authed `/api/fleet/queue*` routes (`fleet_tasks` table), with
|
|
750
|
+
* claim mutual-exclusion enforced by the dispatcher (`claimTaskById`) instead of a file lock.
|
|
751
|
+
* fleet-queue.mjs (the façade one level up) selects between this module and
|
|
752
|
+
* fleet-queue-neon.mjs at runtime — callers of the façade do not change; this file stays as the
|
|
753
|
+
* offline / no-dispatcher fallback and is never selected while an explicit `queuePath` is passed
|
|
754
|
+
* (tests keep pointing at a temp file unchanged).
|
|
755
|
+
*/
|
|
756
|
+
|
|
757
|
+
// ── self-test (local temp queue file — never touches the real .heyspex/fleet-queue.jsonl) ──────
|
|
758
|
+
if (import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? "")) && process.argv.includes("--self-test")) {
|
|
759
|
+
(async () => {
|
|
760
|
+
const { tmpdir } = await import("node:os");
|
|
761
|
+
const { mkdtempSync, rmSync: rm } = await import("node:fs");
|
|
762
|
+
let ok = true;
|
|
763
|
+
const check = (n, c) => {
|
|
764
|
+
console.log(` ${c ? "✓" : "✗"} ${n}`);
|
|
765
|
+
if (!c) ok = false;
|
|
766
|
+
};
|
|
767
|
+
console.log("== fleet-queue-file.mjs self-test ==\n");
|
|
768
|
+
|
|
769
|
+
const dir = mkdtempSync(join(tmpdir(), "hs-fleet-queue-selftest-"));
|
|
770
|
+
const qp = join(dir, "fleet-queue.jsonl");
|
|
771
|
+
|
|
772
|
+
// 1. enqueue: id + 1-indexed position, oldest-first within a lane
|
|
773
|
+
const a = enqueue({ lane: "research-firecrawl", payload: { q: "a" }, requestedBy: "agent-1", queuePath: qp, now: 1000 });
|
|
774
|
+
check("1 first enqueue gets position 1", a.position === 1 && typeof a.id === "string");
|
|
775
|
+
const b = enqueue({ lane: "research-firecrawl", payload: { q: "b" }, requestedBy: "agent-2", queuePath: qp, now: 2000 });
|
|
776
|
+
check("2 second enqueue in same lane gets position 2", b.position === 2);
|
|
777
|
+
const otherLane = enqueue({ lane: "build-gpu0", payload: { job: "x" }, queuePath: qp, now: 2500 });
|
|
778
|
+
check("3 a different lane starts its own position count", otherLane.position === 1);
|
|
779
|
+
|
|
780
|
+
// 2. nextPending returns oldest first, scoped to the lane
|
|
781
|
+
check("4 nextPending returns the oldest pending item in the lane", nextPending("research-firecrawl", { queuePath: qp, now: 3000 })?.id === a.id);
|
|
782
|
+
check("5 nextPending is lane-scoped", nextPending("build-gpu0", { queuePath: qp, now: 3000 })?.id === otherLane.id);
|
|
783
|
+
|
|
784
|
+
// 3. claim -> complete happy path
|
|
785
|
+
const claimA = claim(a.id, "claimant-1", { queuePath: qp, now: 4000 });
|
|
786
|
+
check("6 claim succeeds on a pending item", claimA.ok === true && claimA.reclaimed === false);
|
|
787
|
+
check("7 nextPending skips the now-claimed item", nextPending("research-firecrawl", { queuePath: qp, now: 4500 })?.id === b.id);
|
|
788
|
+
const doneA = complete(a.id, { savedTo: "specs/research/x.md" }, { queuePath: qp, now: 5000 });
|
|
789
|
+
check("8 complete succeeds on a claimed item", doneA.ok === true);
|
|
790
|
+
check("9 list reflects completed status + result", list("research-firecrawl", null, { queuePath: qp, now: 5500 }).find((i) => i.id === a.id)?.status === "completed");
|
|
791
|
+
|
|
792
|
+
// 4. double-claim rejection
|
|
793
|
+
const claimB1 = claim(b.id, "claimant-1", { queuePath: qp, now: 6000 });
|
|
794
|
+
check("10 first claim on b succeeds", claimB1.ok === true);
|
|
795
|
+
const claimB2 = claim(b.id, "claimant-2", { queuePath: qp, now: 6100 });
|
|
796
|
+
check("11 a second claim on the SAME still-live item is rejected", claimB2.ok === false && /already claimed/.test(claimB2.reason));
|
|
797
|
+
|
|
798
|
+
// 5. fail path
|
|
799
|
+
const failB = fail(b.id, "endpoint unreachable", { queuePath: qp, now: 7000 });
|
|
800
|
+
check("12 fail succeeds on a claimed item", failB.ok === true);
|
|
801
|
+
check("13 list reflects failed status + reason", list(null, "failed", { queuePath: qp, now: 7500 }).find((i) => i.id === b.id)?.failReason === "endpoint unreachable");
|
|
802
|
+
check("14 cannot claim a failed (terminal) item", claim(b.id, "claimant-3", { queuePath: qp, now: 7600 }).ok === false);
|
|
803
|
+
check("15 cannot complete an already-completed item", complete(a.id, {}, { queuePath: qp, now: 7700 }).ok === false);
|
|
804
|
+
|
|
805
|
+
// 6. crash-recovery semantics: a claimed-but-stale entry is reclaimable after the TTL
|
|
806
|
+
const c = enqueue({ lane: "research-firecrawl", payload: { q: "c" }, queuePath: qp, now: 10_000 });
|
|
807
|
+
const claimC = claim(c.id, "claimant-crashed", { queuePath: qp, now: 10_100, ttlMs: 1000 });
|
|
808
|
+
check("16 c gets claimed", claimC.ok === true);
|
|
809
|
+
const tooSoon = claim(c.id, "claimant-4", { queuePath: qp, now: 10_500, ttlMs: 1000 }); // 400ms later, still under 1000ms TTL
|
|
810
|
+
check("17 reclaim attempt before TTL expiry is rejected", tooSoon.ok === false);
|
|
811
|
+
const afterTtl = claim(c.id, "claimant-4", { queuePath: qp, now: 11_200, ttlMs: 1000 }); // 1100ms later, TTL exceeded
|
|
812
|
+
check("18 reclaim attempt after TTL expiry succeeds (stale claim reclaimed)", afterTtl.ok === true && afterTtl.reclaimed === true);
|
|
813
|
+
check("19 the ORIGINAL claim event is not erased — still visible in list()'s claimant history via the latest fold", list("research-firecrawl", "claimed", { queuePath: qp, now: 11_300, ttlMs: 1000 }).find((i) => i.id === c.id)?.claimant === "claimant-4");
|
|
814
|
+
|
|
815
|
+
// 7. unknown id handling
|
|
816
|
+
check("20 claim on an unknown id fails cleanly", claim("fq_does_not_exist", "x", { queuePath: qp }).ok === false);
|
|
817
|
+
check("21 complete on an unknown id fails cleanly", complete("fq_does_not_exist", {}, { queuePath: qp }).ok === false);
|
|
818
|
+
|
|
819
|
+
// 8. concurrent double-claim: two "processes" race claim() on the same id — only one wins
|
|
820
|
+
const d = enqueue({ lane: "research-firecrawl", payload: { q: "d" }, queuePath: qp, now: 12_000 });
|
|
821
|
+
const [raceA, raceB] = await Promise.all([
|
|
822
|
+
Promise.resolve().then(() => claim(d.id, "racer-A", { queuePath: qp, now: 12_100 })),
|
|
823
|
+
Promise.resolve().then(() => claim(d.id, "racer-B", { queuePath: qp, now: 12_100 })),
|
|
824
|
+
]);
|
|
825
|
+
const winners = [raceA, raceB].filter((r) => r.ok);
|
|
826
|
+
check("22 concurrent claim() calls on the same id: exactly one wins", winners.length === 1);
|
|
827
|
+
|
|
828
|
+
// 9. append-only: the file only grows, corrupt trailing line tolerated
|
|
829
|
+
const beforeLines = readFileSync(qp, "utf8").split("\n").filter(Boolean).length;
|
|
830
|
+
const { appendFileSync } = await import("node:fs");
|
|
831
|
+
appendFileSync(qp, '{"type":"claim","id":"broken", partial-json');
|
|
832
|
+
check("23 a corrupt trailing line does not throw when reading state", (() => {
|
|
833
|
+
try {
|
|
834
|
+
list(null, null, { queuePath: qp });
|
|
835
|
+
return true;
|
|
836
|
+
} catch {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
})());
|
|
840
|
+
check("24 file only grew (never rewritten) across the whole run", readFileSync(qp, "utf8").split("\n").filter(Boolean).length >= beforeLines);
|
|
841
|
+
|
|
842
|
+
rm(dir, { recursive: true, force: true });
|
|
843
|
+
console.log(`\n${ok ? "✓ self-test PASS" : "✗ self-test FAIL"}`);
|
|
844
|
+
process.exit(ok ? 0 : 1);
|
|
845
|
+
})();
|
|
846
|
+
}
|