session-orchestrator 3.22.0 → 3.24.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-dispatch.mjs — dispatch a wave subagent to a REMOTE host over the
|
|
3
|
+
* `offload` CLI, and measure the result at the produced patch file rather than
|
|
4
|
+
* from the model's own prose (#1160, repo-side half b).
|
|
5
|
+
*
|
|
6
|
+
* Relationship to `foreign-dispatch.mjs`: same contract, different channel.
|
|
7
|
+
* That module sends a task to a foreign MODEL on this machine; this one sends
|
|
8
|
+
* a task to Claude on ANOTHER machine. Both share the parts that must not
|
|
9
|
+
* diverge and are therefore IMPORTED, never copied: the `never_foreign` role
|
|
10
|
+
* lock ({@link isNeverForeignRole}), the run-id path-segment validator
|
|
11
|
+
* ({@link isSafeRunId}), the SIGTERM→SIGKILL child runner ({@link runChild}),
|
|
12
|
+
* and the two budget constants.
|
|
13
|
+
*
|
|
14
|
+
* Three properties are load-bearing here and each has a test:
|
|
15
|
+
*
|
|
16
|
+
* 1. **The prompt travels on STDIN, never in argv.** `offload`'s own help
|
|
17
|
+
* states "prompts travel by file (mode 600), never argv"; argv is visible
|
|
18
|
+
* to every process on the host via `ps`.
|
|
19
|
+
* 2. **The patch never lands in the repo the coordinator commits from.**
|
|
20
|
+
* `--patch` is validated to resolve under `os.tmpdir()` AND outside
|
|
21
|
+
* `repoRoot`, so a remote result cannot be swept into a local commit by
|
|
22
|
+
* an unrelated `git add`.
|
|
23
|
+
* 3. **The patch is READ, never applied.** Applying it is the coordinator's
|
|
24
|
+
* own step, after review — this module returns counts and paths only.
|
|
25
|
+
*
|
|
26
|
+
* The remote side enforces its own timeout; the wall-clock kill here is the
|
|
27
|
+
* backstop for a channel that stops answering (`timeoutSec + 60`).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { emitEvent, sessionAttribution } from '../events.mjs';
|
|
36
|
+
import { isPathInside } from '../path-utils.mjs';
|
|
37
|
+
import {
|
|
38
|
+
DEFAULT_KILL_GRACE_MS,
|
|
39
|
+
DEFAULT_TIMEOUT_SEC,
|
|
40
|
+
isNeverForeignRole,
|
|
41
|
+
isSafeRunId,
|
|
42
|
+
runChild,
|
|
43
|
+
} from './foreign-dispatch.mjs';
|
|
44
|
+
|
|
45
|
+
export { DEFAULT_KILL_GRACE_MS, DEFAULT_TIMEOUT_SEC };
|
|
46
|
+
|
|
47
|
+
/** The `offload` binary. Resolved on PATH; a missing binary surfaces as ENOENT
|
|
48
|
+
* and is classified as a CHANNEL failure, never as a model failure. */
|
|
49
|
+
export const OFFLOAD_BIN = 'offload';
|
|
50
|
+
|
|
51
|
+
/** Local grace added to the remote budget before the wall-clock kill fires, in
|
|
52
|
+
* seconds. The remote side has its own `--timeout`; killing locally at the same
|
|
53
|
+
* instant would race it and report `timeout` for runs the remote was about to
|
|
54
|
+
* report as `empty-diff` or `remote-command-failed`.
|
|
55
|
+
*
|
|
56
|
+
* Named ceiling (BV-004): 60 s covers the sync-back of a patch after the remote
|
|
57
|
+
* command itself has finished. Revisit if a measured healthy run is ever killed
|
|
58
|
+
* locally while the remote reported success — raise this, never drop the kill. */
|
|
59
|
+
export const LOCAL_TIMEOUT_GRACE_SEC = 60;
|
|
60
|
+
|
|
61
|
+
/** Longest run-id / host accepted as an argv value and path segment. */
|
|
62
|
+
export const MAX_ID_LEN = 64;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `offload` exit code → failure reason. Measured against `offload --help`
|
|
66
|
+
* (2026-09-02). Kept as data so a new code shows up as `undefined` (mapped to
|
|
67
|
+
* `remote-command-failed` below) rather than being silently read as success.
|
|
68
|
+
* @type {Readonly<Record<number, string>>}
|
|
69
|
+
*/
|
|
70
|
+
export const OFFLOAD_EXIT_REASONS = Object.freeze({
|
|
71
|
+
1: 'usage-config',
|
|
72
|
+
2: 'host-unreachable',
|
|
73
|
+
3: 'remote-command-failed',
|
|
74
|
+
4: 'sync-failed',
|
|
75
|
+
5: 'timeout',
|
|
76
|
+
6: 'empty-diff',
|
|
77
|
+
7: 'rate-limited',
|
|
78
|
+
8: 'write-lock-busy',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Event name — the only ledger record a remote dispatch produces. A Bash-spawned
|
|
82
|
+
* child fires no `SubagentStop` hook, so nothing else in the chain sees it. */
|
|
83
|
+
export const REMOTE_DISPATCH_EVENT = 'orchestrator.remote_dispatch.completed';
|
|
84
|
+
|
|
85
|
+
/** Chars of merged child output retained per stream on the envelope. Enough for
|
|
86
|
+
* exit-7's reset time and a stack tail; short enough that the envelope stays
|
|
87
|
+
* loggable. Never emitted in telemetry. */
|
|
88
|
+
const TAIL_CHARS = 2000;
|
|
89
|
+
|
|
90
|
+
/** Refuse to parse a patch larger than this; `patchBytes` is still reported.
|
|
91
|
+
* Named ceiling (BV-004): 64 MiB is far past any review-sized diff. Revisit if
|
|
92
|
+
* a legitimate remote patch is ever measured above it. */
|
|
93
|
+
const MAX_PATCH_PARSE_BYTES = 64 * 1024 * 1024;
|
|
94
|
+
|
|
95
|
+
const tail = (s) => (s.length > TAIL_CHARS ? s.slice(-TAIL_CHARS) : s);
|
|
96
|
+
|
|
97
|
+
/** @param {unknown} v @returns {boolean} safe as an argv value and path segment. */
|
|
98
|
+
function isSafeId(v) {
|
|
99
|
+
const s = String(v ?? '');
|
|
100
|
+
return s.length > 0 && s.length <= MAX_ID_LEN && isSafeRunId(s);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Longest `repo` accepted as an argv operand. */
|
|
104
|
+
const MAX_REPO_LEN = 128;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A repo argument safe as the first positional of `offload claude`.
|
|
108
|
+
*
|
|
109
|
+
* Same character allowlist as `SAFE_PATH_RE` in `scripts/lib/config/remote-hosts.mjs`
|
|
110
|
+
* (a `repo-path` declared there is exactly what arrives here), plus two rules the
|
|
111
|
+
* charset alone cannot express: the first character may never be `-`, or the CLI
|
|
112
|
+
* reads the operand as an OPTION and silently consumes the token after it; and
|
|
113
|
+
* `..` may never appear, so a repo argument cannot climb out of the remote's
|
|
114
|
+
* project root.
|
|
115
|
+
*/
|
|
116
|
+
const SAFE_REPO_RE = /^[A-Za-z0-9._~/][A-Za-z0-9._~/-]{0,127}$/;
|
|
117
|
+
|
|
118
|
+
/** @param {unknown} v @returns {boolean} */
|
|
119
|
+
function isSafeRepo(v) {
|
|
120
|
+
const s = String(v ?? '');
|
|
121
|
+
return s.length > 0 && s.length <= MAX_REPO_LEN && SAFE_REPO_RE.test(s) && !s.includes('..');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A model name safe as the argv value of `--model`. Deliberately NARROWER than
|
|
126
|
+
* {@link SAFE_REPO_RE} — a model is a bare name (`sonnet`, `claude-opus-4.5`),
|
|
127
|
+
* never a path — and anchored against a leading `-` for the same option-token
|
|
128
|
+
* reason.
|
|
129
|
+
*/
|
|
130
|
+
const SAFE_MODEL_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]{0,63}$/;
|
|
131
|
+
|
|
132
|
+
/** @param {unknown} v @returns {boolean} */
|
|
133
|
+
function isSafeModel(v) {
|
|
134
|
+
return SAFE_MODEL_RE.test(String(v ?? ''));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Directories a remote patch may live in. Both spellings of the temp directory
|
|
139
|
+
* are accepted because macOS resolves `os.tmpdir()` through a symlink
|
|
140
|
+
* (`/var/folders/...` ↔ `/private/var/folders/...`) and a caller that passed a
|
|
141
|
+
* realpath'd directory is not doing anything unsafe.
|
|
142
|
+
* @returns {string[]}
|
|
143
|
+
*/
|
|
144
|
+
function tmpRoots() {
|
|
145
|
+
const t = path.resolve(os.tmpdir());
|
|
146
|
+
const roots = [t];
|
|
147
|
+
try {
|
|
148
|
+
const real = fs.realpathSync(t);
|
|
149
|
+
if (real !== t) roots.push(real);
|
|
150
|
+
} catch {
|
|
151
|
+
/* tmpdir unreadable — the lexical root still applies */
|
|
152
|
+
}
|
|
153
|
+
return roots;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Paths touched by a unified diff. `+++ b/<path>` covers modified files; a
|
|
158
|
+
* created file has `+++ /dev/null`-free `+++ b/…` too, but a DELETED file's
|
|
159
|
+
* `+++` is `/dev/null`, so `diff --git a/x b/x` is read as well — the same
|
|
160
|
+
* blindness `foreign-dispatch.mjs` documents for `git diff` alone.
|
|
161
|
+
* @param {string} patch
|
|
162
|
+
* @returns {string[]} sorted, de-duplicated
|
|
163
|
+
*/
|
|
164
|
+
export function parsePatchFiles(patch) {
|
|
165
|
+
/** @type {Set<string>} */
|
|
166
|
+
const files = new Set();
|
|
167
|
+
for (const line of String(patch || '').split('\n')) {
|
|
168
|
+
if (line.startsWith('+++ ')) {
|
|
169
|
+
const p = line.slice(4).trim().split('\t')[0];
|
|
170
|
+
if (p && p !== '/dev/null') files.add(p.replace(/^b\//, ''));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (line.startsWith('diff --git ')) {
|
|
174
|
+
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line.trim());
|
|
175
|
+
if (m) files.add(m[2]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return [...files].sort();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Dispatch one wave task to a remote host via `offload claude`.
|
|
183
|
+
*
|
|
184
|
+
* @param {object} task
|
|
185
|
+
* @param {string} task.host — `offload` host alias (`-H`).
|
|
186
|
+
* @param {string} task.repo — repository argument handed to `offload claude`.
|
|
187
|
+
* @param {string} task.prompt — written to the child's STDIN, never to argv.
|
|
188
|
+
* @param {string} task.role — checked against the `never_foreign` lock FIRST.
|
|
189
|
+
* @param {string} task.runId — becomes `--job`; a path segment, hence validated.
|
|
190
|
+
* @param {number} [task.timeoutSec] — remote budget; the local kill adds 60 s.
|
|
191
|
+
* @param {string} [task.model] — `--model`; omitted from argv when absent.
|
|
192
|
+
* @param {string} [task.patchPath] — must resolve under `os.tmpdir()` and
|
|
193
|
+
* outside `repoRoot`. Defaults to `<tmpdir>/offload-<runId>.patch`.
|
|
194
|
+
* @param {object} [deps]
|
|
195
|
+
* @param {Function} [deps.spawnFn] — `child_process.spawn` seam.
|
|
196
|
+
* @param {Function} [deps.now] — clock seam.
|
|
197
|
+
* @param {Function} [deps.emitFn] — event-emitter seam.
|
|
198
|
+
* @param {number} [deps.killGraceMs] — SIGTERM→SIGKILL grace.
|
|
199
|
+
* @param {number} [deps.localGraceSec] — seconds added to the remote budget before
|
|
200
|
+
* the LOCAL wall-clock kill; a seam so the escalation is testable without a
|
|
201
|
+
* real 15-minute wait.
|
|
202
|
+
* @param {string} [deps.repoRoot] — the tree the coordinator commits from.
|
|
203
|
+
* @returns {Promise<object>} result envelope; `ok` is false unless the child
|
|
204
|
+
* exited 0 AND left a non-empty patch.
|
|
205
|
+
*/
|
|
206
|
+
export async function dispatchRemote(
|
|
207
|
+
{ host, repo, prompt, role, runId, timeoutSec = DEFAULT_TIMEOUT_SEC, model, patchPath },
|
|
208
|
+
{
|
|
209
|
+
spawnFn = nodeSpawn,
|
|
210
|
+
now = Date.now,
|
|
211
|
+
emitFn = emitEvent,
|
|
212
|
+
killGraceMs = DEFAULT_KILL_GRACE_MS,
|
|
213
|
+
localGraceSec = LOCAL_TIMEOUT_GRACE_SEC,
|
|
214
|
+
repoRoot,
|
|
215
|
+
} = {},
|
|
216
|
+
) {
|
|
217
|
+
/** Refusal envelope + its telemetry record. A refused dispatch must leave a
|
|
218
|
+
* ledger record rather than a silence, and `exit_code: null` + `patch_files: 0`
|
|
219
|
+
* keep "refused" distinguishable from "attempted and measured empty". */
|
|
220
|
+
const refuse = async (reason) => {
|
|
221
|
+
try {
|
|
222
|
+
await emitFn(
|
|
223
|
+
REMOTE_DISPATCH_EVENT,
|
|
224
|
+
{
|
|
225
|
+
...sessionAttribution(repoRoot),
|
|
226
|
+
host,
|
|
227
|
+
role,
|
|
228
|
+
run_id: runId,
|
|
229
|
+
ok: false,
|
|
230
|
+
reason,
|
|
231
|
+
exit_code: null,
|
|
232
|
+
duration_ms: 0,
|
|
233
|
+
patch_files: 0,
|
|
234
|
+
patch_bytes: 0,
|
|
235
|
+
},
|
|
236
|
+
{ repoRoot },
|
|
237
|
+
);
|
|
238
|
+
} catch {
|
|
239
|
+
/* telemetry must never fail a refusal */
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
reason,
|
|
244
|
+
role,
|
|
245
|
+
host,
|
|
246
|
+
repo,
|
|
247
|
+
runId,
|
|
248
|
+
exitCode: null,
|
|
249
|
+
timedOut: false,
|
|
250
|
+
durationMs: 0,
|
|
251
|
+
patchPath: null,
|
|
252
|
+
patchFiles: [],
|
|
253
|
+
patchBytes: 0,
|
|
254
|
+
stdoutTail: '',
|
|
255
|
+
stderrTail: '',
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Hard role lock FIRST — before any spawn, any file, any side effect.
|
|
260
|
+
if (isNeverForeignRole(role)) return refuse('never-foreign-role');
|
|
261
|
+
if (!isSafeId(runId)) return refuse('unsafe-run-id');
|
|
262
|
+
if (!isSafeId(host)) return refuse('unsafe-host');
|
|
263
|
+
// `repo` is the first positional and `model` the value of `--model`: both
|
|
264
|
+
// reach argv, and both were previously stringified straight into it. A value
|
|
265
|
+
// starting with `-` is read by the CLI as an option, which shifts every
|
|
266
|
+
// operand after it — the same injection class the two checks above prevent.
|
|
267
|
+
if (!isSafeRepo(repo)) return refuse('unsafe-repo');
|
|
268
|
+
// The truthiness test mirrors the `...(model ? ['--model', …] : [])` argv line
|
|
269
|
+
// below on purpose: a falsy model is OMITTED from argv, so it is absent, not
|
|
270
|
+
// unsafe. Validating a shape that never reaches argv would refuse callers for
|
|
271
|
+
// a value the CLI never sees.
|
|
272
|
+
if (model && !isSafeModel(model)) return refuse('unsafe-model');
|
|
273
|
+
|
|
274
|
+
const patchTarget = path.resolve(
|
|
275
|
+
String(patchPath || path.join(os.tmpdir(), `offload-${runId}.patch`)),
|
|
276
|
+
);
|
|
277
|
+
const underTmp = tmpRoots().some((r) => patchTarget === r || isPathInside(patchTarget, r));
|
|
278
|
+
const rootResolved = repoRoot ? path.resolve(String(repoRoot)) : null;
|
|
279
|
+
const insideRepo =
|
|
280
|
+
rootResolved !== null &&
|
|
281
|
+
(patchTarget === rootResolved || isPathInside(patchTarget, rootResolved));
|
|
282
|
+
// A patch inside the coordinator's tree is not a delivery, it is a
|
|
283
|
+
// contamination: the next `git add` sweeps a remote model's output into a
|
|
284
|
+
// local commit with nobody having reviewed it.
|
|
285
|
+
if (!underTmp || insideRepo) return refuse('unsafe-patch-path');
|
|
286
|
+
|
|
287
|
+
const startedAt = now();
|
|
288
|
+
const args = [
|
|
289
|
+
'claude',
|
|
290
|
+
String(repo),
|
|
291
|
+
'-H',
|
|
292
|
+
String(host),
|
|
293
|
+
'--job',
|
|
294
|
+
String(runId),
|
|
295
|
+
'--write',
|
|
296
|
+
'--patch',
|
|
297
|
+
patchTarget,
|
|
298
|
+
...(model ? ['--model', String(model)] : []),
|
|
299
|
+
'--timeout',
|
|
300
|
+
String(timeoutSec),
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
let stdout = '';
|
|
304
|
+
let stderr = '';
|
|
305
|
+
// Wrapping the spawn seam is how the prompt reaches STDIN and how the two
|
|
306
|
+
// streams stay SEPARATE — runChild merges them by design, but exit 7's reset
|
|
307
|
+
// time arrives on stderr and the operator needs it distinguishable.
|
|
308
|
+
const spawnWithStdin = (cmd, argv, options) => {
|
|
309
|
+
const child = spawnFn(cmd, argv, options);
|
|
310
|
+
child.stdout?.on('data', (c) => {
|
|
311
|
+
stdout += c.toString();
|
|
312
|
+
});
|
|
313
|
+
child.stderr?.on('data', (c) => {
|
|
314
|
+
stderr += c.toString();
|
|
315
|
+
});
|
|
316
|
+
try {
|
|
317
|
+
child.stdin?.write(String(prompt ?? ''));
|
|
318
|
+
child.stdin?.end();
|
|
319
|
+
} catch {
|
|
320
|
+
/* a child that died before its stdin opened is reported by exit/error */
|
|
321
|
+
}
|
|
322
|
+
return child;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const { exitCode, timerFired, errorCode } = await runChild(
|
|
326
|
+
OFFLOAD_BIN,
|
|
327
|
+
args,
|
|
328
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
|
329
|
+
{
|
|
330
|
+
spawnFn: spawnWithStdin,
|
|
331
|
+
timeoutMs: (Number(timeoutSec) + Number(localGraceSec)) * 1000,
|
|
332
|
+
killGraceMs,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// A missing patch file and a 0-byte one are the SAME observable state:
|
|
337
|
+
// nothing came back. Never an error class of its own.
|
|
338
|
+
let patchBytes = 0;
|
|
339
|
+
try {
|
|
340
|
+
patchBytes = fs.statSync(patchTarget).size;
|
|
341
|
+
} catch {
|
|
342
|
+
/* no patch file — patchBytes stays 0 */
|
|
343
|
+
}
|
|
344
|
+
/** @type {string[]} */
|
|
345
|
+
let patchFiles = [];
|
|
346
|
+
if (patchBytes > 0 && patchBytes <= MAX_PATCH_PARSE_BYTES) {
|
|
347
|
+
try {
|
|
348
|
+
patchFiles = parsePatchFiles(fs.readFileSync(patchTarget, 'utf8'));
|
|
349
|
+
} catch {
|
|
350
|
+
// Unreadable body, measured size: report the bytes, claim no paths.
|
|
351
|
+
patchFiles = [];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let reason;
|
|
356
|
+
if (errorCode === 'ENOENT') reason = 'channel-unavailable';
|
|
357
|
+
else if (timerFired) reason = 'timeout';
|
|
358
|
+
else if (exitCode !== 0) reason = OFFLOAD_EXIT_REASONS[exitCode] || 'remote-command-failed';
|
|
359
|
+
else if (patchBytes === 0) reason = 'empty-diff';
|
|
360
|
+
const ok = reason === undefined;
|
|
361
|
+
|
|
362
|
+
const durationMs = now() - startedAt;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
await emitFn(
|
|
366
|
+
REMOTE_DISPATCH_EVENT,
|
|
367
|
+
{
|
|
368
|
+
...sessionAttribution(repoRoot),
|
|
369
|
+
host,
|
|
370
|
+
role,
|
|
371
|
+
run_id: runId,
|
|
372
|
+
ok,
|
|
373
|
+
// Present only when the dispatch failed, so absence means success and
|
|
374
|
+
// every failure class is groupable without string-matching a message.
|
|
375
|
+
...(reason ? { reason } : {}),
|
|
376
|
+
exit_code: exitCode,
|
|
377
|
+
duration_ms: durationMs,
|
|
378
|
+
patch_files: patchFiles.length,
|
|
379
|
+
patch_bytes: patchBytes,
|
|
380
|
+
// Deliberately ABSENT: the prompt, the patch body, and `patch_path` —
|
|
381
|
+
// this record also travels over the optional Clank webhook with no
|
|
382
|
+
// redaction, and a tmp path names the run id and the operator's host.
|
|
383
|
+
},
|
|
384
|
+
{ repoRoot },
|
|
385
|
+
);
|
|
386
|
+
} catch {
|
|
387
|
+
/* telemetry must never fail a dispatch */
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
ok,
|
|
392
|
+
...(reason ? { reason } : {}),
|
|
393
|
+
role,
|
|
394
|
+
host,
|
|
395
|
+
repo,
|
|
396
|
+
runId,
|
|
397
|
+
exitCode,
|
|
398
|
+
timedOut: timerFired,
|
|
399
|
+
durationMs,
|
|
400
|
+
patchPath: patchTarget,
|
|
401
|
+
patchFiles,
|
|
402
|
+
patchBytes,
|
|
403
|
+
stdoutTail: tail(stdout),
|
|
404
|
+
stderrTail: tail(stderr),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Parse one `offload doctor --brief` line.
|
|
410
|
+
*
|
|
411
|
+
* Real line, measured 2026-09-02 against host `m5` (the leading hostname is
|
|
412
|
+
* SCRUBBED to the synthetic `Ferdinands-…` convention of
|
|
413
|
+
* `scripts/lib/host-identity.mjs`; every other segment is verbatim):
|
|
414
|
+
* `Ferdinands-Macbook-2 ready=yes · load 14.23 · mem free 96% · headless: slots
|
|
415
|
+
* none, keychain-route ok · 9 jobs · claude procs 12`
|
|
416
|
+
*
|
|
417
|
+
* Every metric is `null` when its segment is absent — never a fabricated `0`,
|
|
418
|
+
* which would read as "measured, idle" for a host that answered nothing.
|
|
419
|
+
* @param {string} raw
|
|
420
|
+
* @returns {{ready: boolean, raw: string, load: number|null, memFreePct: number|null,
|
|
421
|
+
* jobs: number|null, claudeProcs: number|null}}
|
|
422
|
+
*/
|
|
423
|
+
export function parseDoctorLine(raw) {
|
|
424
|
+
const text = String(raw ?? '');
|
|
425
|
+
const num = (re) => {
|
|
426
|
+
const m = re.exec(text);
|
|
427
|
+
return m ? Number(m[1]) : null;
|
|
428
|
+
};
|
|
429
|
+
return {
|
|
430
|
+
// `ready` is the ONLY decision field: anything that is not a literal
|
|
431
|
+
// `ready=yes` is not-ready, including an unparseable line.
|
|
432
|
+
ready: /\bready=yes\b/i.test(text),
|
|
433
|
+
raw: text,
|
|
434
|
+
load: num(/\bload\s+([0-9]+(?:\.[0-9]+)?)/i),
|
|
435
|
+
memFreePct: num(/\bmem free\s+([0-9]+(?:\.[0-9]+)?)\s*%/i),
|
|
436
|
+
jobs: num(/\b([0-9]+)\s+jobs?\b/i),
|
|
437
|
+
claudeProcs: num(/\bclaude procs\s+([0-9]+)/i),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Host readiness check — `offload doctor -H <host> --brief`. Read-only.
|
|
443
|
+
*
|
|
444
|
+
* @param {object} opts
|
|
445
|
+
* @param {string} opts.host
|
|
446
|
+
* @param {Function} [opts.execFn] — `child_process.execFileSync` seam.
|
|
447
|
+
* @param {number} [opts.timeoutMs]
|
|
448
|
+
* @returns {{ready: boolean, raw: string, load: number|null, memFreePct: number|null,
|
|
449
|
+
* jobs: number|null, claudeProcs: number|null}}
|
|
450
|
+
*/
|
|
451
|
+
export function remoteDoctor({ host, execFn = execFileSync, timeoutMs = 90_000 }) {
|
|
452
|
+
if (!isSafeId(host)) return { ...parseDoctorLine(''), raw: '' };
|
|
453
|
+
let raw;
|
|
454
|
+
try {
|
|
455
|
+
raw = String(
|
|
456
|
+
execFn(OFFLOAD_BIN, ['doctor', '-H', String(host), '--brief'], {
|
|
457
|
+
encoding: 'utf8',
|
|
458
|
+
timeout: timeoutMs,
|
|
459
|
+
}),
|
|
460
|
+
).trim();
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// A non-zero exit still carries diagnostic text on stdout/stderr; keep it
|
|
463
|
+
// so the operator sees WHY, but never let it produce metrics.
|
|
464
|
+
const out = err && (err.stdout || err.stderr);
|
|
465
|
+
return {
|
|
466
|
+
ready: false,
|
|
467
|
+
raw: out ? String(out).trim() : String((err && err.message) || ''),
|
|
468
|
+
load: null,
|
|
469
|
+
memFreePct: null,
|
|
470
|
+
jobs: null,
|
|
471
|
+
claudeProcs: null,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return parseDoctorLine(raw);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* THE adapter between {@link remoteDoctor} and the wave-resource gate's
|
|
479
|
+
* `probeFn` seam (`scripts/lib/wave-resource-gate.mjs` → `applyOffloadDecision`).
|
|
480
|
+
*
|
|
481
|
+
* The gate's witness contract is `async (alias: string) => boolean`;
|
|
482
|
+
* `remoteDoctor` is SYNC, takes an options object and returns a metrics record.
|
|
483
|
+
* Passing `remoteDoctor` itself as `probeFn` therefore yields `undefined.ready`
|
|
484
|
+
* on an object it never received an alias for, and no host is ever ready — the
|
|
485
|
+
* documented witness could not produce an `offload` decision at all. This
|
|
486
|
+
* function is the one shape both sides agree on; use it, never `remoteDoctor`
|
|
487
|
+
* directly.
|
|
488
|
+
*
|
|
489
|
+
* Never throws: a probe that raises is a host that did not answer, i.e. NOT
|
|
490
|
+
* ready. The gate must fail toward local, never toward a host it cannot vouch
|
|
491
|
+
* for.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} alias — declared host alias (`remote-hosts[].alias`).
|
|
494
|
+
* @param {object} [opts]
|
|
495
|
+
* @param {Function} [opts.execFn] — `child_process.execFileSync` seam.
|
|
496
|
+
* @returns {Promise<boolean>} true only on a literal `ready=yes` line.
|
|
497
|
+
*/
|
|
498
|
+
export async function remoteReadyProbe(alias, { execFn } = {}) {
|
|
499
|
+
try {
|
|
500
|
+
return remoteDoctor({ host: alias, ...(execFn ? { execFn } : {}) }).ready === true;
|
|
501
|
+
} catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -8,6 +8,25 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { probe, evaluate } from './resource-probe.mjs';
|
|
11
|
+
import { isNeverForeignRole } from './wave-executor/foreign-dispatch.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wave roles that may run on a declared remote host, mapped to the
|
|
15
|
+
* `agent-mapping` role a host must list in its `roles-allowed` (#1160).
|
|
16
|
+
*
|
|
17
|
+
* Two enums meet here: the WAVE role ("Quality", "Impl-Core") on the left, the
|
|
18
|
+
* agent-mapping role ("test", "ui", "perf") on the right. Anything absent from
|
|
19
|
+
* this map is local-only — the default is "do not offload", so a wave role added
|
|
20
|
+
* later never becomes offloadable by accident.
|
|
21
|
+
*
|
|
22
|
+
* @type {Readonly<Record<string, string>>}
|
|
23
|
+
*/
|
|
24
|
+
export const OFFLOADABLE_WAVE_ROLES = Object.freeze({
|
|
25
|
+
quality: 'test',
|
|
26
|
+
test: 'test',
|
|
27
|
+
ui: 'ui',
|
|
28
|
+
perf: 'perf',
|
|
29
|
+
});
|
|
11
30
|
|
|
12
31
|
// ---------------------------------------------------------------------------
|
|
13
32
|
// Public API
|
|
@@ -87,11 +106,101 @@ async function extractMeasurements(opts) {
|
|
|
87
106
|
*
|
|
88
107
|
* @param {{ramFreeGb: number, ramAvailableGb?: number|null, cpuLoadPct: number, cpuLoad5mPct?: number|null, concurrentSessions: number}} measurements
|
|
89
108
|
* @param {object} opts - Same opts shape as evaluateWaveResourceGate
|
|
90
|
-
* @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
|
|
109
|
+
* @returns {Promise<{decision: string, agents: number, reasons: string[], measurements: object, host?: string}>}
|
|
91
110
|
*/
|
|
92
|
-
function applyDecisionRules(measurements, opts) {
|
|
111
|
+
async function applyDecisionRules(measurements, opts) {
|
|
93
112
|
const result = computeResourceDecision(measurements, opts);
|
|
94
|
-
|
|
113
|
+
// Order is load-bearing: the heavy-repo cap is a STATIC property of the repo
|
|
114
|
+
// and only ever lowers; offload is a PLACEMENT answer to live pressure. Running
|
|
115
|
+
// offload last means a capped wave that gets offloaded still respects HR-004,
|
|
116
|
+
// and never the reverse.
|
|
117
|
+
return applyOffloadDecision(applyHeavyRepoCap(result, opts), opts);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* #1160 offload placement. When the resource rules want to SHRINK the wave
|
|
122
|
+
* (`reduce`) or take it away entirely (`coordinator-direct`), and the repo
|
|
123
|
+
* declares a remote host that accepts this wave role, route the wave to that
|
|
124
|
+
* host at its full planned agent count instead of shrinking it.
|
|
125
|
+
*
|
|
126
|
+
* The gate does NOT probe the network — a placement decision must stay a pure
|
|
127
|
+
* function of its inputs. The caller supplies a readiness WITNESS:
|
|
128
|
+
* - `opts.remoteReady` — `{ [alias]: boolean }`, e.g. built from the
|
|
129
|
+
* SessionStart `Offload m5: ready=yes` banner or `remoteDoctor()`.
|
|
130
|
+
* - `opts.probeFn` — `async (alias) => boolean`, consulted only for hosts the
|
|
131
|
+
* `remoteReady` map does not already answer for. Default `null`. A probeFn
|
|
132
|
+
* that REJECTS is read as not-ready and its message is appended to
|
|
133
|
+
* `reasons` — the gate never loses the local decision to a failed witness.
|
|
134
|
+
* With neither supplied NO host is ready and the decision stays `reduce` /
|
|
135
|
+
* `coordinator-direct` — the gate fails toward local, never toward a host it
|
|
136
|
+
* cannot vouch for.
|
|
137
|
+
*
|
|
138
|
+
* @param {{decision: string, agents: number, reasons: string[], measurements: object}} result
|
|
139
|
+
* @param {object} opts - Same opts shape as evaluateWaveResourceGate
|
|
140
|
+
* @returns {Promise<{decision: string, agents: number, reasons: string[], measurements: object, host?: string}>}
|
|
141
|
+
*/
|
|
142
|
+
async function applyOffloadDecision(result, opts) {
|
|
143
|
+
if (result.decision !== 'reduce' && result.decision !== 'coordinator-direct') return result;
|
|
144
|
+
|
|
145
|
+
const { config, plannedAgents, waveRole, remoteReady, probeFn = null } = opts;
|
|
146
|
+
const hosts = config?.['remote-hosts'];
|
|
147
|
+
if (!Array.isArray(hosts) || hosts.length === 0) return result;
|
|
148
|
+
|
|
149
|
+
const role = String(waveRole ?? '').trim().toLowerCase();
|
|
150
|
+
if (isNeverForeignRole(role)) return result;
|
|
151
|
+
const mappedRole = OFFLOADABLE_WAVE_ROLES[role];
|
|
152
|
+
if (mappedRole === undefined) return result;
|
|
153
|
+
|
|
154
|
+
const ready = remoteReady && typeof remoteReady === 'object' ? remoteReady : {};
|
|
155
|
+
// A witness that THROWS is a host that did not answer — never a reason to lose
|
|
156
|
+
// the local decision the resource rules already computed. Before this catch, a
|
|
157
|
+
// rejecting probeFn (ssh down, `offload` missing) propagated all the way out of
|
|
158
|
+
// evaluateWaveResourceGate and rejected the whole gate call.
|
|
159
|
+
/** @type {string[]} */
|
|
160
|
+
const probeFailures = [];
|
|
161
|
+
// First fit in DECLARATION order — the operator's order is the preference order.
|
|
162
|
+
let host;
|
|
163
|
+
for (const h of hosts) {
|
|
164
|
+
if (!Array.isArray(h?.['roles-allowed']) || !h['roles-allowed'].includes(mappedRole)) continue;
|
|
165
|
+
let isReady = ready[h.alias] === true;
|
|
166
|
+
if (!isReady && ready[h.alias] === undefined && typeof probeFn === 'function') {
|
|
167
|
+
try {
|
|
168
|
+
isReady = (await probeFn(h.alias)) === true;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
isReady = false;
|
|
171
|
+
probeFailures.push(
|
|
172
|
+
`offload probe for '${h.alias}' failed (${(err && err.message) || String(err)}) — staying local`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (isReady) {
|
|
177
|
+
host = h;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (host === undefined) {
|
|
182
|
+
return probeFailures.length === 0
|
|
183
|
+
? result
|
|
184
|
+
: { ...result, reasons: [...result.reasons, ...probeFailures] };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The wave runs at its planned size again — but never above the HR-004 static
|
|
188
|
+
// ceiling, which is a property of the REPO and holds wherever the wave runs.
|
|
189
|
+
// Restoring plannedAgents unconditionally here would let a heavy repo exceed
|
|
190
|
+
// its own cap by way of a remote host.
|
|
191
|
+
const cap = config?.['heavy-repo'] === true ? resolveApwCap(config['agents-per-wave']) : null;
|
|
192
|
+
const agents = cap === null ? plannedAgents : Math.min(plannedAgents, cap);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
decision: 'offload',
|
|
196
|
+
agents,
|
|
197
|
+
host: host.alias,
|
|
198
|
+
reasons: [
|
|
199
|
+
...result.reasons,
|
|
200
|
+
`offload: ${role} routed to host '${host.alias}' instead of reducing to ${result.agents}`,
|
|
201
|
+
],
|
|
202
|
+
measurements: result.measurements,
|
|
203
|
+
};
|
|
95
204
|
}
|
|
96
205
|
|
|
97
206
|
/**
|
|
@@ -243,7 +352,15 @@ function computeResourceDecision(measurements, opts) {
|
|
|
243
352
|
* @param {string} opts.waveRole - e.g. "Impl-Core", "Quality"
|
|
244
353
|
* @param {object} [opts.probeOverride] - {ramFreeGb, cpuLoadPct, cpuLoad5mPct?, concurrentSessions}
|
|
245
354
|
* for testing; when omitted, calls resource-probe
|
|
246
|
-
* @
|
|
355
|
+
* @param {Record<string, boolean>} [opts.remoteReady] - #1160 readiness witness per
|
|
356
|
+
* declared host alias. The gate never probes the network itself; without a witness
|
|
357
|
+
* no host counts as ready and the decision stays local.
|
|
358
|
+
* @param {(alias: string) => Promise<boolean>} [opts.probeFn] - optional async witness,
|
|
359
|
+
* consulted only for aliases absent from `remoteReady`. Default null. Use
|
|
360
|
+
* `remoteReadyProbe` from scripts/lib/wave-executor/remote-dispatch.mjs; a
|
|
361
|
+
* rejection counts as not-ready and is reported in `reasons`, never thrown.
|
|
362
|
+
* @returns {Promise<{decision: "proceed"|"reduce"|"coordinator-direct"|"offload", agents: number, reasons: string[], measurements: object, host?: string}>}
|
|
363
|
+
* `host` is present only on an `offload` decision — the declared alias the wave runs on.
|
|
247
364
|
*/
|
|
248
365
|
export async function evaluateWaveResourceGate(opts) {
|
|
249
366
|
const { config, plannedAgents } = opts;
|
|
@@ -280,11 +397,11 @@ export async function evaluateWaveResourceGate(opts) {
|
|
|
280
397
|
|
|
281
398
|
/**
|
|
282
399
|
* Format a gate result into a short multi-line coordinator progress string.
|
|
283
|
-
* @param {{decision: string, agents: number, reasons: string[], measurements: object}} result
|
|
400
|
+
* @param {{decision: string, agents: number, reasons: string[], measurements: object, host?: string}} result
|
|
284
401
|
* @returns {string}
|
|
285
402
|
*/
|
|
286
403
|
export function formatGateReport(result) {
|
|
287
|
-
const { decision, agents, reasons, measurements } = result;
|
|
404
|
+
const { decision, agents, reasons, measurements, host } = result;
|
|
288
405
|
const lines = reasons.map((r) => ` - ${r}`);
|
|
289
406
|
const m = measurements;
|
|
290
407
|
// Prefer the macOS available-RAM figure in the banner when present (#667):
|
|
@@ -297,6 +414,9 @@ export function formatGateReport(result) {
|
|
|
297
414
|
Object.keys(m).length > 0
|
|
298
415
|
? ` (${ramStr}, CPU ${m.cpuLoadPct ?? '?'}%, sessions ${m.concurrentSessions ?? '?'})`
|
|
299
416
|
: '';
|
|
300
|
-
|
|
417
|
+
// An `offload` decision without its host in the banner is unreadable: the agent
|
|
418
|
+
// count did not shrink, so the line would look identical to `proceed` (HR-106).
|
|
419
|
+
const agentsStr = decision === 'offload' && host ? `${agents} @ ${host}` : `${agents}`;
|
|
420
|
+
lines.push(`Decision: ${decision} — agents: ${agentsStr}${measStr}`);
|
|
301
421
|
return lines.join('\n');
|
|
302
422
|
}
|