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,426 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fleet-queue.mjs — a generic, file-backed queue for any machine-gated fleet resource.
|
|
4
|
+
*
|
|
5
|
+
* Owner ask (2026-07-12): "If I have one agent already sending research request in progress
|
|
6
|
+
* to Night Action, and another agent has research that needs to be done, that agent can
|
|
7
|
+
* submit it successfully to the research queue - instead of getting a fail or rejection.
|
|
8
|
+
* This queue can also be used when Night Action has an existing ollama process. Research can
|
|
9
|
+
* be sent to the queue to be presented to Night Action as soon as it is done." And: "this
|
|
10
|
+
* queue concept can be used across the whole fleet for different use cases."
|
|
11
|
+
*
|
|
12
|
+
* FIRST LANE: "research-firecrawl" — research requests that lose the Ollama-XOR-Firecrawl
|
|
13
|
+
* mode-lease race in night-action-mode.mjs's requestOrEnqueueFirecrawlMode() land here
|
|
14
|
+
* instead of failing (see night-action-mode.mjs). Lanes are free-form strings; any future
|
|
15
|
+
* machine-gated resource (a build slot, a breakdown job, a GPU node) reuses this same module
|
|
16
|
+
* under its own lane name — see the design note at the bottom of this file.
|
|
17
|
+
*
|
|
18
|
+
* STORAGE — event-sourced, append-only JSONL: `.heyspex/fleet-queue.jsonl` (repo root,
|
|
19
|
+
* resolved by walking up to the heyspex.yaml vault marker — same convention as
|
|
20
|
+
* dev-factory/lib/fleet-state.mjs). Every mutation (enqueue / claim / complete / fail) is
|
|
21
|
+
* appended as ONE JSON line via a fresh open("a") + write + close (mirrors
|
|
22
|
+
* dev-factory/lib/run-events.mjs) — never a whole-file rewrite. Current item state is derived
|
|
23
|
+
* by FOLDING an item's events in file order, the same event-sourcing shape
|
|
24
|
+
* .heyspex/claim-history.jsonl and .heyspex/focus-history.jsonl already use elsewhere in this
|
|
25
|
+
* repo for durable ledgers (append pattern confirmed against both before writing this).
|
|
26
|
+
*
|
|
27
|
+
* WHY EVENT-SOURCED APPEND OVER REWRITE-WITH-STATUS (the crash-safety justification the task
|
|
28
|
+
* asked for): a whole-file rewrite (read all items, mutate one, write a temp file, rename over
|
|
29
|
+
* the original) is atomic PER WRITE but requires a read-modify-write on the FULL file for every
|
|
30
|
+
* single mutation, and a crash between "read" and "rename" either loses the mutation or (worse,
|
|
31
|
+
* on a partial rename) can corrupt every other item's state too. An append-only event log makes
|
|
32
|
+
* each mutation a single new line: a crash mid-write can only ever cost the LAST line (which
|
|
33
|
+
* readAllEvents() below already tolerates — a corrupt/partial trailing line is skipped, not
|
|
34
|
+
* thrown, mirroring run-events.mjs), and it can NEVER corrupt or lose any prior item's history.
|
|
35
|
+
* The trade is that readers must fold history instead of reading a snapshot — cheap here since
|
|
36
|
+
* the queue is small and local.
|
|
37
|
+
*
|
|
38
|
+
* CONCURRENCY — double-claim rejection: enqueue/complete/fail don't need cross-process
|
|
39
|
+
* exclusion (each only ever touches its own new line or an item only its own claimant should be
|
|
40
|
+
* mutating), but claim() has a genuine race — two processes could both read "pending" for the
|
|
41
|
+
* same id before either appends its claim event. claim() (and complete/fail, for symmetry) take
|
|
42
|
+
* a short-lived, per-id, TTL-bounded advisory file lock (`<queue>.lock.<id>`, exclusive-create
|
|
43
|
+
* `wx`) around their read-decide-append critical section — true mutual exclusion for same-id
|
|
44
|
+
* ops across processes, while different ids proceed fully in parallel. A lock file left behind
|
|
45
|
+
* by a crashed holder is auto-broken after `staleLockMs` (default 5s) so the queue can never
|
|
46
|
+
* wedge on a dead process.
|
|
47
|
+
*
|
|
48
|
+
* CRASH-RECOVERY (claim TTL): a claim() that never gets a matching complete()/fail() within
|
|
49
|
+
* `CLAIM_TTL_MS` (default 15 min, override via options or HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) is
|
|
50
|
+
* treated as STALE — effectiveStatus() reports it back as "pending" so any caller (including
|
|
51
|
+
* the original claimant retrying after a crash) can reclaim it. The stale claim's history is
|
|
52
|
+
* never deleted, only superseded by a fresh claim event — so `list()` still shows the full
|
|
53
|
+
* claim history for audit.
|
|
54
|
+
*
|
|
55
|
+
* API:
|
|
56
|
+
* enqueue({ lane, payload, requestedBy }) -> { id, position } // position = 1-indexed rank among pending items in that lane
|
|
57
|
+
* nextPending(lane) -> item | null // oldest pending (or stale-reclaimable) item in the lane
|
|
58
|
+
* claim(id, claimant) -> { ok, reason? } // rejects double-claim / terminal states
|
|
59
|
+
* complete(id, result?) -> { ok, reason? } // must currently be claimed
|
|
60
|
+
* fail(id, reason?) -> { ok, reason? } // from pending or claimed
|
|
61
|
+
* list(lane?, status?) -> item[] // oldest-first
|
|
62
|
+
*
|
|
63
|
+
* CLI: node dev-factory/lib/fleet-queue.mjs --self-test
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
|
|
67
|
+
import { dirname, join } from "node:path";
|
|
68
|
+
import { codeRepoRoot } from "./code-repo-root.mjs";
|
|
69
|
+
|
|
70
|
+
// ── repo-root + store resolution: this queue holds CODE-repo state (fleet-queue.jsonl), not
|
|
71
|
+
// vault state, so it anchors on the code repo itself rather than walking for a vault marker.
|
|
72
|
+
export function findRepoRoot() {
|
|
73
|
+
return codeRepoRoot();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultQueuePath() {
|
|
77
|
+
const dir = join(findRepoRoot(), ".heyspex");
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
return process.env.HEYSPEX_FLEET_QUEUE_PATH || join(dir, "fleet-queue.jsonl");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const CLAIM_TTL_MS = Number(process.env.HEYSPEX_FLEET_QUEUE_CLAIM_TTL_MS) || 15 * 60 * 1000; // 15 min
|
|
83
|
+
|
|
84
|
+
/** First lane (owner ask 2026-07-12): research requests that lose the Ollama-XOR-Firecrawl
|
|
85
|
+
* mode-lease race in night-action-mode.mjs land here instead of failing. */
|
|
86
|
+
export const RESEARCH_FIRECRAWL_LANE = "research-firecrawl";
|
|
87
|
+
|
|
88
|
+
// ── event log I/O (mirrors run-events.mjs: line-atomic append, corrupt-tail-tolerant read) ──────
|
|
89
|
+
function appendEvent(queuePath, record) {
|
|
90
|
+
mkdirSync(dirname(queuePath), { recursive: true });
|
|
91
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
92
|
+
const fd = openSync(queuePath, "a");
|
|
93
|
+
try {
|
|
94
|
+
writeSync(fd, line);
|
|
95
|
+
} finally {
|
|
96
|
+
closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
return record;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readAllEvents(queuePath) {
|
|
102
|
+
if (!existsSync(queuePath)) return [];
|
|
103
|
+
const text = readFileSync(queuePath, "utf8");
|
|
104
|
+
const events = [];
|
|
105
|
+
for (const line of text.split("\n")) {
|
|
106
|
+
const trimmed = line.trim();
|
|
107
|
+
if (!trimmed) continue;
|
|
108
|
+
try {
|
|
109
|
+
events.push(JSON.parse(trimmed));
|
|
110
|
+
} catch {
|
|
111
|
+
// corrupt/partial trailing line (e.g. a crash mid-write) — skip, never throw
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return events;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── per-id advisory lock (exclusive-create; auto-breaks a stale/crashed holder) ─────────────────
|
|
118
|
+
function lockPathFor(queuePath, id) {
|
|
119
|
+
return `${queuePath}.lock.${id}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function acquireLock(queuePath, id, { timeoutMs = 3000, staleLockMs = 5000, pollMs = 15 } = {}) {
|
|
123
|
+
const lp = lockPathFor(queuePath, id);
|
|
124
|
+
mkdirSync(dirname(queuePath), { recursive: true });
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
for (;;) {
|
|
127
|
+
try {
|
|
128
|
+
const fd = openSync(lp, "wx");
|
|
129
|
+
writeSync(fd, String(process.pid));
|
|
130
|
+
closeSync(fd);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (e?.code !== "EEXIST") throw e;
|
|
134
|
+
try {
|
|
135
|
+
const st = statSync(lp);
|
|
136
|
+
if (Date.now() - st.mtimeMs > staleLockMs) {
|
|
137
|
+
rmSync(lp, { force: true }); // break a lock left by a crashed holder
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
continue; // lock vanished between the failed create and the stat — retry immediately
|
|
142
|
+
}
|
|
143
|
+
if (Date.now() - start > timeoutMs) return false;
|
|
144
|
+
// best-effort short busy-wait; this is a local dev tool, not a high-throughput queue
|
|
145
|
+
const until = Date.now() + pollMs;
|
|
146
|
+
while (Date.now() < until) { /* spin */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function releaseLock(queuePath, id) {
|
|
152
|
+
try {
|
|
153
|
+
rmSync(lockPathFor(queuePath, id), { force: true });
|
|
154
|
+
} catch {
|
|
155
|
+
// already gone — fine
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function withLock(queuePath, id, fn) {
|
|
160
|
+
if (!acquireLock(queuePath, id)) return { ok: false, reason: `fleet-queue: could not acquire lock for "${id}" (held too long)` };
|
|
161
|
+
try {
|
|
162
|
+
return fn();
|
|
163
|
+
} finally {
|
|
164
|
+
releaseLock(queuePath, id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── state derivation (event fold) ─────────────────────────────────────────────────────────────
|
|
169
|
+
function foldOne(events) {
|
|
170
|
+
let state = null;
|
|
171
|
+
for (const ev of events) {
|
|
172
|
+
if (ev.type === "enqueue") {
|
|
173
|
+
state = {
|
|
174
|
+
id: ev.id,
|
|
175
|
+
lane: ev.lane,
|
|
176
|
+
payload: ev.payload ?? null,
|
|
177
|
+
requestedBy: ev.requestedBy ?? null,
|
|
178
|
+
status: "pending",
|
|
179
|
+
enqueuedAt: ev.at,
|
|
180
|
+
claimant: null,
|
|
181
|
+
claimedAt: null,
|
|
182
|
+
result: null,
|
|
183
|
+
failReason: null,
|
|
184
|
+
completedAt: null,
|
|
185
|
+
};
|
|
186
|
+
} else if (!state) {
|
|
187
|
+
continue; // orphan event with no enqueue in this log slice — ignore
|
|
188
|
+
} else if (ev.type === "claim") {
|
|
189
|
+
state.status = "claimed";
|
|
190
|
+
state.claimant = ev.claimant ?? null;
|
|
191
|
+
state.claimedAt = ev.at;
|
|
192
|
+
} else if (ev.type === "complete") {
|
|
193
|
+
state.status = "completed";
|
|
194
|
+
state.result = ev.result ?? null;
|
|
195
|
+
state.completedAt = ev.at;
|
|
196
|
+
} else if (ev.type === "fail") {
|
|
197
|
+
state.status = "failed";
|
|
198
|
+
state.failReason = ev.reason ?? null;
|
|
199
|
+
state.completedAt = ev.at;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return state;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Raw folded state (claimed stays "claimed" even if stale) keyed by id, oldest enqueue first. */
|
|
206
|
+
function deriveRaw(queuePath) {
|
|
207
|
+
const events = readAllEvents(queuePath);
|
|
208
|
+
const byId = new Map();
|
|
209
|
+
for (const ev of events) {
|
|
210
|
+
if (!ev?.id) continue;
|
|
211
|
+
if (!byId.has(ev.id)) byId.set(ev.id, []);
|
|
212
|
+
byId.get(ev.id).push(ev);
|
|
213
|
+
}
|
|
214
|
+
const out = new Map();
|
|
215
|
+
for (const [id, evs] of byId) {
|
|
216
|
+
const s = foldOne(evs);
|
|
217
|
+
if (s) out.set(id, s);
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Folded state with the TTL rule applied: a claim older than ttlMs reports back as "pending"
|
|
223
|
+
* (reclaimable) without erasing claimant/claimedAt history — callers can see it was stale. */
|
|
224
|
+
function deriveEffective(queuePath, { now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
225
|
+
const raw = deriveRaw(queuePath);
|
|
226
|
+
const out = new Map();
|
|
227
|
+
for (const [id, s] of raw) {
|
|
228
|
+
const stale = s.status === "claimed" && now - Date.parse(s.claimedAt) > ttlMs;
|
|
229
|
+
out.set(id, stale ? { ...s, status: "pending", stale: true } : { ...s, stale: false });
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function genId() {
|
|
235
|
+
return `fq_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── public API ────────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
export function enqueue({ lane, payload = null, requestedBy = null, queuePath = defaultQueuePath(), now = Date.now() } = {}) {
|
|
240
|
+
if (!lane) throw new Error("fleet-queue.enqueue: lane is required");
|
|
241
|
+
const id = genId();
|
|
242
|
+
const at = new Date(now).toISOString();
|
|
243
|
+
appendEvent(queuePath, { type: "enqueue", id, lane, payload, requestedBy, at });
|
|
244
|
+
const items = [...deriveEffective(queuePath, { now }).values()]
|
|
245
|
+
.filter((s) => s.lane === lane && s.status === "pending")
|
|
246
|
+
.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
247
|
+
const position = items.findIndex((s) => s.id === id) + 1;
|
|
248
|
+
return { id, position };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function nextPending(lane, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
252
|
+
const items = [...deriveEffective(queuePath, { now, ttlMs }).values()]
|
|
253
|
+
.filter((s) => s.lane === lane && s.status === "pending")
|
|
254
|
+
.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
255
|
+
return items[0] ?? null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function claim(id, claimant, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
259
|
+
if (!id) return { ok: false, reason: "fleet-queue.claim: id is required" };
|
|
260
|
+
if (!claimant) return { ok: false, reason: "fleet-queue.claim: claimant is required" };
|
|
261
|
+
return withLock(queuePath, id, () => {
|
|
262
|
+
const state = deriveEffective(queuePath, { now, ttlMs }).get(id);
|
|
263
|
+
if (!state) return { ok: false, reason: `unknown id "${id}"` };
|
|
264
|
+
if (state.status === "completed") return { ok: false, reason: "already completed" };
|
|
265
|
+
if (state.status === "failed") return { ok: false, reason: "already failed" };
|
|
266
|
+
if (state.status === "claimed") return { ok: false, reason: `already claimed by ${state.claimant}` };
|
|
267
|
+
appendEvent(queuePath, { type: "claim", id, claimant, at: new Date(now).toISOString() });
|
|
268
|
+
return { ok: true, id, claimant, reclaimed: Boolean(state.stale) };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function complete(id, result = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
|
|
273
|
+
if (!id) return { ok: false, reason: "fleet-queue.complete: id is required" };
|
|
274
|
+
return withLock(queuePath, id, () => {
|
|
275
|
+
const state = deriveEffective(queuePath, { now }).get(id);
|
|
276
|
+
if (!state) return { ok: false, reason: `unknown id "${id}"` };
|
|
277
|
+
if (state.status !== "claimed") return { ok: false, reason: `cannot complete from status "${state.status}" (must be claimed)` };
|
|
278
|
+
appendEvent(queuePath, { type: "complete", id, result, at: new Date(now).toISOString() });
|
|
279
|
+
return { ok: true, id };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function fail(id, reason = null, { queuePath = defaultQueuePath(), now = Date.now() } = {}) {
|
|
284
|
+
if (!id) return { ok: false, reason: "fleet-queue.fail: id is required" };
|
|
285
|
+
return withLock(queuePath, id, () => {
|
|
286
|
+
const state = deriveEffective(queuePath, { now }).get(id);
|
|
287
|
+
if (!state) return { ok: false, reason: `unknown id "${id}"` };
|
|
288
|
+
if (state.status === "completed" || state.status === "failed") {
|
|
289
|
+
return { ok: false, reason: `cannot fail from terminal status "${state.status}"` };
|
|
290
|
+
}
|
|
291
|
+
appendEvent(queuePath, { type: "fail", id, reason, at: new Date(now).toISOString() });
|
|
292
|
+
return { ok: true, id };
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function list(lane = null, status = null, { queuePath = defaultQueuePath(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
|
|
297
|
+
const items = [...deriveEffective(queuePath, { now, ttlMs }).values()];
|
|
298
|
+
return items
|
|
299
|
+
.filter((s) => (lane ? s.lane === lane : true))
|
|
300
|
+
.filter((s) => (status ? s.status === status : true))
|
|
301
|
+
.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/*
|
|
305
|
+
* ── DESIGN NOTE: how this generalizes fleet-wide, and the future upgrade path ──────────────────
|
|
306
|
+
*
|
|
307
|
+
* This module is deliberately domain-blind: `lane` is a free-form string, `payload` is opaque
|
|
308
|
+
* JSON, and nothing in enqueue/claim/complete/fail/list knows about research, Firecrawl, or
|
|
309
|
+
* Night Action. The first lane ("research-firecrawl") is a CONSUMER of this queue, not part of
|
|
310
|
+
* its design. Any other machine-gated resource in the fleet can stand up its own lane the same
|
|
311
|
+
* way, with zero changes to this file:
|
|
312
|
+
* - build jobs waiting on a busy GPU box -> lane "build-<machine>"
|
|
313
|
+
* - breakdown/critic jobs waiting on the DeepSeek seat -> lane "breakdown-deepseek"
|
|
314
|
+
* - any single-tenant resource with a "one workload at a time" mode lease, mirroring
|
|
315
|
+
* night-action-mode.mjs's Ollama-XOR-Firecrawl shape (mode lease refuses -> caller enqueues
|
|
316
|
+
* instead of failing -> a drain step pops the lane once the resource frees up)
|
|
317
|
+
* The pattern each consumer follows is the same three-step shape night-action-mode.mjs
|
|
318
|
+
* demonstrates: (1) try the gated resource directly, (2) on refusal, enqueue() instead of
|
|
319
|
+
* returning a hard failure, (3) a --drain (or equivalent poll) on the resource-owning side pops
|
|
320
|
+
* nextPending()/list() once the resource is free and dispatches the work.
|
|
321
|
+
*
|
|
322
|
+
* MULTI-MACHINE UPGRADE PATH: this file-backed JSONL queue is correct for a SINGLE shared
|
|
323
|
+
* filesystem (one repo checkout, or machines that all see the same network share) — the
|
|
324
|
+
* per-id file lock only provides mutual exclusion within one filesystem. The moment lanes need
|
|
325
|
+
* to be claimed/drained from genuinely separate machines with no shared disk (the fleet's
|
|
326
|
+
* long-run shape), the natural port is a Neon control-plane table with the SAME event-sourced
|
|
327
|
+
* shape (an append-only `fleet_queue_events` table, `claim` as an `INSERT ... WHERE NOT EXISTS a
|
|
328
|
+
* live claim` guarded by a DB-level unique constraint instead of a file lock, `nextPending` as an
|
|
329
|
+
* indexed `ORDER BY enqueued_at LIMIT 1` query) — the API surface above (enqueue/nextPending/
|
|
330
|
+
* claim/complete/fail/list) stays identical, so callers do not change; only defaultQueuePath()'s
|
|
331
|
+
* resolution swaps for a connection string. Do not build that port speculatively — this local
|
|
332
|
+
* file-backed version is the right MVP for the current single-box (Night Action) + single-vault
|
|
333
|
+
* (this repo) topology, and the Neon swap is scoped exactly when a second always-on machine
|
|
334
|
+
* needs to claim from the same lane.
|
|
335
|
+
*/
|
|
336
|
+
|
|
337
|
+
// ── self-test (local temp queue file — never touches the real .heyspex/fleet-queue.jsonl) ──────
|
|
338
|
+
if (import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? "")) && process.argv.includes("--self-test")) {
|
|
339
|
+
(async () => {
|
|
340
|
+
const { tmpdir } = await import("node:os");
|
|
341
|
+
const { mkdtempSync, rmSync: rm } = await import("node:fs");
|
|
342
|
+
let ok = true;
|
|
343
|
+
const check = (n, c) => {
|
|
344
|
+
console.log(` ${c ? "✓" : "✗"} ${n}`);
|
|
345
|
+
if (!c) ok = false;
|
|
346
|
+
};
|
|
347
|
+
console.log("== fleet-queue.mjs self-test ==\n");
|
|
348
|
+
|
|
349
|
+
const dir = mkdtempSync(join(tmpdir(), "hs-fleet-queue-selftest-"));
|
|
350
|
+
const qp = join(dir, "fleet-queue.jsonl");
|
|
351
|
+
|
|
352
|
+
// 1. enqueue: id + 1-indexed position, oldest-first within a lane
|
|
353
|
+
const a = enqueue({ lane: "research-firecrawl", payload: { q: "a" }, requestedBy: "agent-1", queuePath: qp, now: 1000 });
|
|
354
|
+
check("1 first enqueue gets position 1", a.position === 1 && typeof a.id === "string");
|
|
355
|
+
const b = enqueue({ lane: "research-firecrawl", payload: { q: "b" }, requestedBy: "agent-2", queuePath: qp, now: 2000 });
|
|
356
|
+
check("2 second enqueue in same lane gets position 2", b.position === 2);
|
|
357
|
+
const otherLane = enqueue({ lane: "build-gpu0", payload: { job: "x" }, queuePath: qp, now: 2500 });
|
|
358
|
+
check("3 a different lane starts its own position count", otherLane.position === 1);
|
|
359
|
+
|
|
360
|
+
// 2. nextPending returns oldest first, scoped to the lane
|
|
361
|
+
check("4 nextPending returns the oldest pending item in the lane", nextPending("research-firecrawl", { queuePath: qp, now: 3000 })?.id === a.id);
|
|
362
|
+
check("5 nextPending is lane-scoped", nextPending("build-gpu0", { queuePath: qp, now: 3000 })?.id === otherLane.id);
|
|
363
|
+
|
|
364
|
+
// 3. claim -> complete happy path
|
|
365
|
+
const claimA = claim(a.id, "claimant-1", { queuePath: qp, now: 4000 });
|
|
366
|
+
check("6 claim succeeds on a pending item", claimA.ok === true && claimA.reclaimed === false);
|
|
367
|
+
check("7 nextPending skips the now-claimed item", nextPending("research-firecrawl", { queuePath: qp, now: 4500 })?.id === b.id);
|
|
368
|
+
const doneA = complete(a.id, { savedTo: "specs/research/x.md" }, { queuePath: qp, now: 5000 });
|
|
369
|
+
check("8 complete succeeds on a claimed item", doneA.ok === true);
|
|
370
|
+
check("9 list reflects completed status + result", list("research-firecrawl", null, { queuePath: qp, now: 5500 }).find((i) => i.id === a.id)?.status === "completed");
|
|
371
|
+
|
|
372
|
+
// 4. double-claim rejection
|
|
373
|
+
const claimB1 = claim(b.id, "claimant-1", { queuePath: qp, now: 6000 });
|
|
374
|
+
check("10 first claim on b succeeds", claimB1.ok === true);
|
|
375
|
+
const claimB2 = claim(b.id, "claimant-2", { queuePath: qp, now: 6100 });
|
|
376
|
+
check("11 a second claim on the SAME still-live item is rejected", claimB2.ok === false && /already claimed/.test(claimB2.reason));
|
|
377
|
+
|
|
378
|
+
// 5. fail path
|
|
379
|
+
const failB = fail(b.id, "endpoint unreachable", { queuePath: qp, now: 7000 });
|
|
380
|
+
check("12 fail succeeds on a claimed item", failB.ok === true);
|
|
381
|
+
check("13 list reflects failed status + reason", list(null, "failed", { queuePath: qp, now: 7500 }).find((i) => i.id === b.id)?.failReason === "endpoint unreachable");
|
|
382
|
+
check("14 cannot claim a failed (terminal) item", claim(b.id, "claimant-3", { queuePath: qp, now: 7600 }).ok === false);
|
|
383
|
+
check("15 cannot complete an already-completed item", complete(a.id, {}, { queuePath: qp, now: 7700 }).ok === false);
|
|
384
|
+
|
|
385
|
+
// 6. crash-recovery semantics: a claimed-but-stale entry is reclaimable after the TTL
|
|
386
|
+
const c = enqueue({ lane: "research-firecrawl", payload: { q: "c" }, queuePath: qp, now: 10_000 });
|
|
387
|
+
const claimC = claim(c.id, "claimant-crashed", { queuePath: qp, now: 10_100, ttlMs: 1000 });
|
|
388
|
+
check("16 c gets claimed", claimC.ok === true);
|
|
389
|
+
const tooSoon = claim(c.id, "claimant-4", { queuePath: qp, now: 10_500, ttlMs: 1000 }); // 400ms later, still under 1000ms TTL
|
|
390
|
+
check("17 reclaim attempt before TTL expiry is rejected", tooSoon.ok === false);
|
|
391
|
+
const afterTtl = claim(c.id, "claimant-4", { queuePath: qp, now: 11_200, ttlMs: 1000 }); // 1100ms later, TTL exceeded
|
|
392
|
+
check("18 reclaim attempt after TTL expiry succeeds (stale claim reclaimed)", afterTtl.ok === true && afterTtl.reclaimed === true);
|
|
393
|
+
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");
|
|
394
|
+
|
|
395
|
+
// 7. unknown id handling
|
|
396
|
+
check("20 claim on an unknown id fails cleanly", claim("fq_does_not_exist", "x", { queuePath: qp }).ok === false);
|
|
397
|
+
check("21 complete on an unknown id fails cleanly", complete("fq_does_not_exist", {}, { queuePath: qp }).ok === false);
|
|
398
|
+
|
|
399
|
+
// 8. concurrent double-claim: two "processes" race claim() on the same id — only one wins
|
|
400
|
+
const d = enqueue({ lane: "research-firecrawl", payload: { q: "d" }, queuePath: qp, now: 12_000 });
|
|
401
|
+
const [raceA, raceB] = await Promise.all([
|
|
402
|
+
Promise.resolve().then(() => claim(d.id, "racer-A", { queuePath: qp, now: 12_100 })),
|
|
403
|
+
Promise.resolve().then(() => claim(d.id, "racer-B", { queuePath: qp, now: 12_100 })),
|
|
404
|
+
]);
|
|
405
|
+
const winners = [raceA, raceB].filter((r) => r.ok);
|
|
406
|
+
check("22 concurrent claim() calls on the same id: exactly one wins", winners.length === 1);
|
|
407
|
+
|
|
408
|
+
// 9. append-only: the file only grows, corrupt trailing line tolerated
|
|
409
|
+
const beforeLines = readFileSync(qp, "utf8").split("\n").filter(Boolean).length;
|
|
410
|
+
const { appendFileSync } = await import("node:fs");
|
|
411
|
+
appendFileSync(qp, '{"type":"claim","id":"broken", partial-json');
|
|
412
|
+
check("23 a corrupt trailing line does not throw when reading state", (() => {
|
|
413
|
+
try {
|
|
414
|
+
list(null, null, { queuePath: qp });
|
|
415
|
+
return true;
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
})());
|
|
420
|
+
check("24 file only grew (never rewritten) across the whole run", readFileSync(qp, "utf8").split("\n").filter(Boolean).length >= beforeLines);
|
|
421
|
+
|
|
422
|
+
rm(dir, { recursive: true, force: true });
|
|
423
|
+
console.log(`\n${ok ? "✓ self-test PASS" : "✗ self-test FAIL"}`);
|
|
424
|
+
process.exit(ok ? 0 : 1);
|
|
425
|
+
})();
|
|
426
|
+
}
|
|
@@ -29,22 +29,18 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
32
|
-
import {
|
|
32
|
+
import { join } from "node:path";
|
|
33
33
|
import { pathToFileURL } from "node:url";
|
|
34
34
|
import { tmpdir } from "node:os";
|
|
35
|
+
import { codeRepoRoot } from "./code-repo-root.mjs";
|
|
36
|
+
import { OLLAMA } from "./model-catalog.mjs";
|
|
35
37
|
|
|
36
38
|
const SCHEMA_VERSION = 1;
|
|
37
39
|
|
|
38
|
-
// ── Repo-root resolution
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (existsSync(join(dir, "heyspex.yaml"))) return dir;
|
|
43
|
-
const parent = dirname(dir);
|
|
44
|
-
if (parent === dir) break;
|
|
45
|
-
dir = parent;
|
|
46
|
-
}
|
|
47
|
-
return resolve(startDir);
|
|
40
|
+
// ── Repo-root resolution: this store holds CODE-repo state (fleet-state.json), not vault
|
|
41
|
+
// state, so it anchors on the code repo itself rather than walking for a vault marker.
|
|
42
|
+
export function findRepoRoot() {
|
|
43
|
+
return codeRepoRoot();
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
function heyspexDir(opts = {}) {
|
|
@@ -184,20 +180,20 @@ function runSelfTest() {
|
|
|
184
180
|
return s.version === 1 && Object.keys(s.machines).length === 0;
|
|
185
181
|
})());
|
|
186
182
|
|
|
187
|
-
const first = recordProbe("night-action", { ok: true, models: [
|
|
183
|
+
const first = recordProbe("night-action", { ok: true, models: [OLLAMA.BUILDER] }, opts);
|
|
188
184
|
check("first ok probe creates the machine with firstSeen/lastSeen/lastProbe stamped",
|
|
189
185
|
first.firstSeen != null && first.lastSeen != null && first.lastProbe?.ok === true);
|
|
190
186
|
check("first-ever models land in newModels (nothing is silently 'known')",
|
|
191
|
-
first.newModels.includes(
|
|
187
|
+
first.newModels.includes(OLLAMA.BUILDER) && first.knownModels.length === 0);
|
|
192
188
|
|
|
193
|
-
acknowledgeModels("night-action", [
|
|
189
|
+
acknowledgeModels("night-action", [OLLAMA.BUILDER], opts);
|
|
194
190
|
const acked = loadFleetState(opts).machines["night-action"];
|
|
195
191
|
check("acknowledgeModels moves the model newModels → knownModels",
|
|
196
|
-
acked.knownModels.includes(
|
|
192
|
+
acked.knownModels.includes(OLLAMA.BUILDER) && !acked.newModels.includes(OLLAMA.BUILDER));
|
|
197
193
|
|
|
198
|
-
const drift = recordProbe("night-action", { ok: true, models: [
|
|
194
|
+
const drift = recordProbe("night-action", { ok: true, models: [OLLAMA.BUILDER, "qwen3:32b"] }, opts);
|
|
199
195
|
check("re-probe diffs against knownModels — only the NEW model joins newModels",
|
|
200
|
-
drift.newModels.length === 1 && drift.newModels[0] === "qwen3:32b" && drift.knownModels.includes(
|
|
196
|
+
drift.newModels.length === 1 && drift.newModels[0] === "qwen3:32b" && drift.knownModels.includes(OLLAMA.BUILDER));
|
|
201
197
|
|
|
202
198
|
const seenBefore = drift.lastSeen;
|
|
203
199
|
const failed = recordProbe("night-action", { ok: false }, opts);
|
|
@@ -213,7 +209,7 @@ function runSelfTest() {
|
|
|
213
209
|
|
|
214
210
|
check("state persists across a fresh load (atomic tmp+rename write)", (() => {
|
|
215
211
|
const s = loadFleetState(opts);
|
|
216
|
-
return s.machines["night-action"]?.knownModels.includes(
|
|
212
|
+
return s.machines["night-action"]?.knownModels.includes(OLLAMA.BUILDER)
|
|
217
213
|
&& s.machines["basement-geek"]?.fleetVersion === "0.3.1"
|
|
218
214
|
&& s.version === 1;
|
|
219
215
|
})());
|