@sentropic/h2a-runtime 0.81.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/dist/account-pool.d.ts +168 -0
- package/dist/account-pool.d.ts.map +1 -0
- package/dist/account-pool.js +385 -0
- package/dist/account-pool.js.map +1 -0
- package/dist/agents-projection.d.ts +56 -0
- package/dist/agents-projection.d.ts.map +1 -0
- package/dist/agents-projection.js +82 -0
- package/dist/agents-projection.js.map +1 -0
- package/dist/attach.d.ts +75 -0
- package/dist/attach.d.ts.map +1 -0
- package/dist/attach.js +432 -0
- package/dist/attach.js.map +1 -0
- package/dist/auth-bundle.d.ts +21 -0
- package/dist/auth-bundle.d.ts.map +1 -0
- package/dist/auth-bundle.js +112 -0
- package/dist/auth-bundle.js.map +1 -0
- package/dist/auth-diagnostics.d.ts +18 -0
- package/dist/auth-diagnostics.d.ts.map +1 -0
- package/dist/auth-diagnostics.js +23 -0
- package/dist/auth-diagnostics.js.map +1 -0
- package/dist/auth-login.d.ts +9 -0
- package/dist/auth-login.d.ts.map +1 -0
- package/dist/auth-login.js +21 -0
- package/dist/auth-login.js.map +1 -0
- package/dist/auth-refresh.d.ts +32 -0
- package/dist/auth-refresh.d.ts.map +1 -0
- package/dist/auth-refresh.js +83 -0
- package/dist/auth-refresh.js.map +1 -0
- package/dist/auth-tools.d.ts +55 -0
- package/dist/auth-tools.d.ts.map +1 -0
- package/dist/auth-tools.js +163 -0
- package/dist/auth-tools.js.map +1 -0
- package/dist/browser-bridge-local.d.ts +27 -0
- package/dist/browser-bridge-local.d.ts.map +1 -0
- package/dist/browser-bridge-local.js +51 -0
- package/dist/browser-bridge-local.js.map +1 -0
- package/dist/browser.d.ts +53 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +61 -0
- package/dist/browser.js.map +1 -0
- package/dist/conductor-launch.d.ts +179 -0
- package/dist/conductor-launch.d.ts.map +1 -0
- package/dist/conductor-launch.js +414 -0
- package/dist/conductor-launch.js.map +1 -0
- package/dist/config.d.ts +182 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +334 -0
- package/dist/config.js.map +1 -0
- package/dist/conv-guard.d.ts +76 -0
- package/dist/conv-guard.d.ts.map +1 -0
- package/dist/conv-guard.js +131 -0
- package/dist/conv-guard.js.map +1 -0
- package/dist/convsync.d.ts +24 -0
- package/dist/convsync.d.ts.map +1 -0
- package/dist/convsync.js +108 -0
- package/dist/convsync.js.map +1 -0
- package/dist/cred-health.d.ts +96 -0
- package/dist/cred-health.d.ts.map +1 -0
- package/dist/cred-health.js +170 -0
- package/dist/cred-health.js.map +1 -0
- package/dist/delegate.d.ts +406 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +739 -0
- package/dist/delegate.js.map +1 -0
- package/dist/enroll.d.ts +91 -0
- package/dist/enroll.d.ts.map +1 -0
- package/dist/enroll.js +248 -0
- package/dist/enroll.js.map +1 -0
- package/dist/fanout.d.ts +63 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +115 -0
- package/dist/fanout.js.map +1 -0
- package/dist/forward.d.ts +66 -0
- package/dist/forward.d.ts.map +1 -0
- package/dist/forward.js +141 -0
- package/dist/forward.js.map +1 -0
- package/dist/gitdiff.d.ts +31 -0
- package/dist/gitdiff.d.ts.map +1 -0
- package/dist/gitdiff.js +133 -0
- package/dist/gitdiff.js.map +1 -0
- package/dist/h2a-bridge.d.ts +102 -0
- package/dist/h2a-bridge.d.ts.map +1 -0
- package/dist/h2a-bridge.js +333 -0
- package/dist/h2a-bridge.js.map +1 -0
- package/dist/h2a-jobs.d.ts +235 -0
- package/dist/h2a-jobs.d.ts.map +1 -0
- package/dist/h2a-jobs.js +388 -0
- package/dist/h2a-jobs.js.map +1 -0
- package/dist/h2a-ping.d.ts +38 -0
- package/dist/h2a-ping.d.ts.map +1 -0
- package/dist/h2a-ping.js +58 -0
- package/dist/h2a-ping.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5887 -0
- package/dist/index.js.map +1 -0
- package/dist/interactive-throttle.d.ts +164 -0
- package/dist/interactive-throttle.d.ts.map +1 -0
- package/dist/interactive-throttle.js +194 -0
- package/dist/interactive-throttle.js.map +1 -0
- package/dist/lineage-client.d.ts +77 -0
- package/dist/lineage-client.d.ts.map +1 -0
- package/dist/lineage-client.js +117 -0
- package/dist/lineage-client.js.map +1 -0
- package/dist/lineage-lease.d.ts +152 -0
- package/dist/lineage-lease.d.ts.map +1 -0
- package/dist/lineage-lease.js +303 -0
- package/dist/lineage-lease.js.map +1 -0
- package/dist/llm-gateway-runtime/accounts.d.ts +48 -0
- package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/accounts.js +226 -0
- package/dist/llm-gateway-runtime/accounts.js.map +1 -0
- package/dist/llm-gateway-runtime/index.d.ts +4 -0
- package/dist/llm-gateway-runtime/index.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/index.js +58 -0
- package/dist/llm-gateway-runtime/index.js.map +1 -0
- package/dist/llm-gateway-runtime/model-catalog.d.ts +36 -0
- package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/model-catalog.js +146 -0
- package/dist/llm-gateway-runtime/model-catalog.js.map +1 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +3 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js +139 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -0
- package/dist/llm-gateway-runtime/proxy-openai.d.ts +117 -0
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/proxy-openai.js +1132 -0
- package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -0
- package/dist/llm-gateway-runtime/session-ledger.d.ts +32 -0
- package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/session-ledger.js +62 -0
- package/dist/llm-gateway-runtime/session-ledger.js.map +1 -0
- package/dist/llm-gateway-runtime/sticky.d.ts +40 -0
- package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/sticky.js +254 -0
- package/dist/llm-gateway-runtime/sticky.js.map +1 -0
- package/dist/llm-mesh.d.ts +102 -0
- package/dist/llm-mesh.d.ts.map +1 -0
- package/dist/llm-mesh.js +433 -0
- package/dist/llm-mesh.js.map +1 -0
- package/dist/migrate-candidates.d.ts +35 -0
- package/dist/migrate-candidates.d.ts.map +1 -0
- package/dist/migrate-candidates.js +128 -0
- package/dist/migrate-candidates.js.map +1 -0
- package/dist/migrate.d.ts +125 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +561 -0
- package/dist/migrate.js.map +1 -0
- package/dist/plugin-manifest.d.ts +143 -0
- package/dist/plugin-manifest.d.ts.map +1 -0
- package/dist/plugin-manifest.js +233 -0
- package/dist/plugin-manifest.js.map +1 -0
- package/dist/plugin.d.ts +274 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +919 -0
- package/dist/plugin.js.map +1 -0
- package/dist/pod-liveness.d.ts +51 -0
- package/dist/pod-liveness.d.ts.map +1 -0
- package/dist/pod-liveness.js +80 -0
- package/dist/pod-liveness.js.map +1 -0
- package/dist/profile-menu.d.ts +7 -0
- package/dist/profile-menu.d.ts.map +1 -0
- package/dist/profile-menu.js +52 -0
- package/dist/profile-menu.js.map +1 -0
- package/dist/profiles.d.ts +23 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +66 -0
- package/dist/profiles.js.map +1 -0
- package/dist/protocol-local.d.ts +27 -0
- package/dist/protocol-local.d.ts.map +1 -0
- package/dist/protocol-local.js +12 -0
- package/dist/protocol-local.js.map +1 -0
- package/dist/pty.d.ts +26 -0
- package/dist/pty.d.ts.map +1 -0
- package/dist/pty.js +45 -0
- package/dist/pty.js.map +1 -0
- package/dist/readiness.d.ts +38 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +169 -0
- package/dist/readiness.js.map +1 -0
- package/dist/registry.d.ts +258 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +570 -0
- package/dist/registry.js.map +1 -0
- package/dist/relaunch.d.ts +52 -0
- package/dist/relaunch.d.ts.map +1 -0
- package/dist/relaunch.js +76 -0
- package/dist/relaunch.js.map +1 -0
- package/dist/restore.d.ts +165 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +530 -0
- package/dist/restore.js.map +1 -0
- package/dist/run.d.ts +29 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +206 -0
- package/dist/run.js.map +1 -0
- package/dist/secrets.d.ts +25 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +98 -0
- package/dist/secrets.js.map +1 -0
- package/dist/session-restore.d.ts +26 -0
- package/dist/session-restore.d.ts.map +1 -0
- package/dist/session-restore.js +122 -0
- package/dist/session-restore.js.map +1 -0
- package/dist/skills-sync.d.ts +126 -0
- package/dist/skills-sync.d.ts.map +1 -0
- package/dist/skills-sync.js +255 -0
- package/dist/skills-sync.js.map +1 -0
- package/dist/smoke.d.ts +23 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +138 -0
- package/dist/smoke.js.map +1 -0
- package/dist/soft-refresh.d.ts +107 -0
- package/dist/soft-refresh.d.ts.map +1 -0
- package/dist/soft-refresh.js +426 -0
- package/dist/soft-refresh.js.map +1 -0
- package/dist/sync-status.d.ts +22 -0
- package/dist/sync-status.d.ts.map +1 -0
- package/dist/sync-status.js +41 -0
- package/dist/sync-status.js.map +1 -0
- package/dist/sync.d.ts +77 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +302 -0
- package/dist/sync.js.map +1 -0
- package/dist/throttle-signatures.d.ts +43 -0
- package/dist/throttle-signatures.d.ts.map +1 -0
- package/dist/throttle-signatures.js +116 -0
- package/dist/throttle-signatures.js.map +1 -0
- package/dist/tmux.d.ts +286 -0
- package/dist/tmux.d.ts.map +1 -0
- package/dist/tmux.js +970 -0
- package/dist/tmux.js.map +1 -0
- package/dist/tunnel.d.ts +25 -0
- package/dist/tunnel.d.ts.map +1 -0
- package/dist/tunnel.js +127 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/workspace-merge.d.ts +17 -0
- package/dist/workspace-merge.d.ts.map +1 -0
- package/dist/workspace-merge.js +153 -0
- package/dist/workspace-merge.js.map +1 -0
- package/dist/workspace-sync-incremental.d.ts +64 -0
- package/dist/workspace-sync-incremental.d.ts.map +1 -0
- package/dist/workspace-sync-incremental.js +199 -0
- package/dist/workspace-sync-incremental.js.map +1 -0
- package/dist/workspace-sync.d.ts +3 -0
- package/dist/workspace-sync.d.ts.map +1 -0
- package/dist/workspace-sync.js +187 -0
- package/dist/workspace-sync.js.map +1 -0
- package/dist/workspace.d.ts +70 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +173 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +44 -0
package/dist/delegate.js
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remote delegate <type> "<task>"` — P1 of cross-type agent delegation.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a LIVE, INTERACTIVE agent (claude / codex / agy) in a DETACHED tmux
|
|
5
|
+
* session, PRIMED with the task, with the h2a MCP server in a side window (the
|
|
6
|
+
* existing `remote run --h2a` path) so the parent/master feedback loop works.
|
|
7
|
+
* Returns a job id (slug). The job is enrolled in the SAME registry as sessions
|
|
8
|
+
* with `role: "job"` (no second jobs.json), so `listLive` + the liveness guards
|
|
9
|
+
* + the atomic write are reused as-is.
|
|
10
|
+
*
|
|
11
|
+
* Isolation: by default, if the cwd is a git repo, each job runs in its OWN git
|
|
12
|
+
* worktree under `<cwd>/.remote/jobs/<jobId>/wt` — the single-writer guard only
|
|
13
|
+
* protects the conversation `.jsonl`, not the file tree, so concurrent jobs in
|
|
14
|
+
* the shared cwd would clobber each other. `--cwd` overrides; a non-repo cwd is
|
|
15
|
+
* used as-is.
|
|
16
|
+
*
|
|
17
|
+
* `--headless` is the run-once-exit variant (NOT drop-to-shell): it runs
|
|
18
|
+
* `claude -p` / `codex exec`, redirects stdout+stderr to `<dir>/output.log`,
|
|
19
|
+
* writes `<dir>/result.json {state,exitCode}`, then ENDS the tmux session.
|
|
20
|
+
*
|
|
21
|
+
* SECURITY: the task is passed as a SINGLE argv token — never concatenated into
|
|
22
|
+
* a shell string. The jobId / `--name` pass `assertSafeName` before becoming a
|
|
23
|
+
* tmux slug, a directory, or a filename.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { occupiesSlot } from "./registry.js";
|
|
29
|
+
import { humanAge } from "./migrate-candidates.js";
|
|
30
|
+
const DELEGATE_BIN = {
|
|
31
|
+
claude: "claude",
|
|
32
|
+
codex: "codex",
|
|
33
|
+
agy: "agy",
|
|
34
|
+
};
|
|
35
|
+
/** Job id / `--name`: becomes a tmux slug, a dir, a filename — keep it tame. */
|
|
36
|
+
const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
|
|
37
|
+
export function assertSafeName(name) {
|
|
38
|
+
if (!SAFE_NAME.test(name)) {
|
|
39
|
+
throw new Error(`invalid job name "${name}" (allowed: letters, digits, "_", "-")`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function isDelegateType(value) {
|
|
43
|
+
return value === "claude" || value === "codex" || value === "agy";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The EXACT argv for the agent binary, by type and mode. Pure, exported for
|
|
47
|
+
* tests. The task is ALWAYS a single trailing token (no shell concat):
|
|
48
|
+
* - interactive: the agent starts live, primed with the task as a positional
|
|
49
|
+
* initial prompt (claude/codex/agy all accept a bare positional prompt);
|
|
50
|
+
* - headless: a run-once-exit print mode — claude `-p`, codex `exec`. agy has
|
|
51
|
+
* no confirmed headless mode (design R3), so headless agy throws.
|
|
52
|
+
*
|
|
53
|
+
* Returns `{ command, args }` where args ends with the task token.
|
|
54
|
+
*/
|
|
55
|
+
export function buildDelegateArgs(type, task, headless, model, effort) {
|
|
56
|
+
const command = DELEGATE_BIN[type];
|
|
57
|
+
// Flags prepended before the task/subcommand. Claude supports both
|
|
58
|
+
// --model and --effort; Codex exposes model via -m (global flag before
|
|
59
|
+
// subcommands); agy: ignore unknown flags (passed positional only).
|
|
60
|
+
const modelFlags = (() => {
|
|
61
|
+
if (!model && !effort)
|
|
62
|
+
return [];
|
|
63
|
+
switch (type) {
|
|
64
|
+
case "claude": {
|
|
65
|
+
const flags = [];
|
|
66
|
+
if (model)
|
|
67
|
+
flags.push("--model", model);
|
|
68
|
+
if (effort)
|
|
69
|
+
flags.push("--effort", effort);
|
|
70
|
+
return flags;
|
|
71
|
+
}
|
|
72
|
+
case "codex":
|
|
73
|
+
return model ? ["-m", model] : [];
|
|
74
|
+
case "agy":
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
if (!headless) {
|
|
79
|
+
// Interactive + initial prompt — task is the last argv token.
|
|
80
|
+
return { command, args: [...modelFlags, task] };
|
|
81
|
+
}
|
|
82
|
+
switch (type) {
|
|
83
|
+
case "claude":
|
|
84
|
+
return { command, args: [...modelFlags, "-p", task] };
|
|
85
|
+
case "codex":
|
|
86
|
+
return { command, args: [...modelFlags, "exec", task] };
|
|
87
|
+
case "agy":
|
|
88
|
+
throw new Error("agy has no confirmed headless mode — run it interactively (drop --headless)");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Reliability slice 1 — rate-limit "throttled" RESUME (HEADLESS LOCAL only).
|
|
93
|
+
//
|
|
94
|
+
// When a HEADLESS LOCAL job finished on a transient provider rate-limit, the
|
|
95
|
+
// conductor RELAUNCHES the SAME job in its `runCwd` with the tool's CONTINUE
|
|
96
|
+
// flag so it picks up its prior conversation instead of starting over. The
|
|
97
|
+
// continue flag is derived from the job's type; the task still rides as a single
|
|
98
|
+
// trailing argv token (NEVER `bash -lc` string-concat — same safe channel as
|
|
99
|
+
// `buildDelegateArgs`). PURE arg derivation, exported for tests.
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/** Hard cap on auto-resume attempts before a throttled job is failed `rate-limited`. */
|
|
102
|
+
export const THROTTLE_MAX_ATTEMPTS = 6;
|
|
103
|
+
/** Backoff base (ms) for the resume schedule. */
|
|
104
|
+
export const THROTTLE_BACKOFF_BASE_MS = 60_000; // 60s
|
|
105
|
+
/** Backoff cap (ms) for the resume schedule. */
|
|
106
|
+
export const THROTTLE_BACKOFF_CAP_MS = 30 * 60_000; // 30 min
|
|
107
|
+
/**
|
|
108
|
+
* The EXACT argv to RESUME a throttled HEADLESS job, by type. The job continues
|
|
109
|
+
* its prior conversation (rather than re-running from scratch) and re-prints to
|
|
110
|
+
* the same log, so a fresh success settles it `done`:
|
|
111
|
+
* - claude: `-p --continue <task>` (print mode, continue the last conversation).
|
|
112
|
+
* - codex: `exec resume --last <task>` (resume the most recent rollout).
|
|
113
|
+
* - agy: no confirmed headless mode → throws (same as buildDelegateArgs).
|
|
114
|
+
*
|
|
115
|
+
* The task stays a single trailing token (no shell concat). Pure, exported for
|
|
116
|
+
* tests.
|
|
117
|
+
*
|
|
118
|
+
* TODO(phase-2, interactive): an INTERACTIVE throttled tmux job would instead be
|
|
119
|
+
* nudged via `send-keys` (guarded by an attached-pane check so we never type
|
|
120
|
+
* into a pane the user is driving). Not in this slice.
|
|
121
|
+
*/
|
|
122
|
+
export function buildThrottleResumeArgs(type, task) {
|
|
123
|
+
const command = DELEGATE_BIN[type];
|
|
124
|
+
switch (type) {
|
|
125
|
+
case "claude":
|
|
126
|
+
return { command, args: ["-p", "--continue", task] };
|
|
127
|
+
case "codex":
|
|
128
|
+
return { command, args: ["exec", "resume", "--last", task] };
|
|
129
|
+
case "agy":
|
|
130
|
+
throw new Error("agy has no confirmed headless resume mode — interactive resume is phase 2");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Full-jitter exponential backoff delay for the throttle RESUME schedule —
|
|
135
|
+
* copied from the session-agent's `websocket-transport.ts` `jitteredDelay`
|
|
136
|
+
* (same formula, kept local so this module stays pure/dependency-free).
|
|
137
|
+
* Returns a random value in [0, min(cap, base * 2^attempt)]. `attempt` is
|
|
138
|
+
* 0-based (the FIRST throttle passes attempt 0). `rand` is injectable so tests
|
|
139
|
+
* pin the jitter. Pure, exported for tests.
|
|
140
|
+
*/
|
|
141
|
+
export function jitteredBackoffMs(attempt, baseMs = THROTTLE_BACKOFF_BASE_MS, capMs = THROTTLE_BACKOFF_CAP_MS, rand = Math.random) {
|
|
142
|
+
const ceiling = Math.min(capMs, baseMs * Math.pow(2, Math.max(0, attempt)));
|
|
143
|
+
return rand() * ceiling;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The CEILING of the full-jitter window for a given attempt: `min(cap, base *
|
|
147
|
+
* 2^attempt)`. Monotonic non-decreasing in `attempt`, capped at `capMs`. The
|
|
148
|
+
* jittered delay is always in `[0, ceiling]`. Pure, exported so tests can assert
|
|
149
|
+
* the schedule is monotonic + bounded without depending on the random draw.
|
|
150
|
+
*/
|
|
151
|
+
export function backoffCeilingMs(attempt, baseMs = THROTTLE_BACKOFF_BASE_MS, capMs = THROTTLE_BACKOFF_CAP_MS) {
|
|
152
|
+
return Math.min(capMs, baseMs * Math.pow(2, Math.max(0, attempt)));
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Reliability slice 1 — AIMD circuit breaker for ADMITTING pending jobs.
|
|
156
|
+
//
|
|
157
|
+
// A provider rate-limit is ACCOUNT-WIDE: if jobs are already throttling, starting
|
|
158
|
+
// MORE pending jobs just burns the same quota and they throttle too. So we
|
|
159
|
+
// MULTIPLICATIVELY DECREASE the effective admission cap when a burst of throttles
|
|
160
|
+
// is observed (≥2 jobs entered `throttled` within the last 10 min → halve), and
|
|
161
|
+
// ADDITIVELY INCREASE it (+1) on every clean pass (no new throttle), restoring up
|
|
162
|
+
// to the configured cap. Classic AIMD. PURE: given (configuredCap,
|
|
163
|
+
// throttleHistory, now[, lastCap]) it returns the effective cap; the conductor
|
|
164
|
+
// keeps `lastCap` across passes. Exported for tests.
|
|
165
|
+
//
|
|
166
|
+
// `throttled` jobs KEEP their slot (registry.occupiesSlot), so this only governs
|
|
167
|
+
// admitting NEW `pending` work — it never evicts a throttled job.
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
/** Window over which throttle events are counted for the breaker (10 min). */
|
|
170
|
+
export const AIMD_WINDOW_MS = 10 * 60_000;
|
|
171
|
+
/** ≥ this many throttles in the window trips the multiplicative decrease. */
|
|
172
|
+
export const AIMD_TRIP_THRESHOLD = 2;
|
|
173
|
+
/**
|
|
174
|
+
* The effective admission cap under AIMD. Pure.
|
|
175
|
+
*
|
|
176
|
+
* - Count throttle events with `firstAt`/`at` within `[now - AIMD_WINDOW_MS, now]`.
|
|
177
|
+
* - If that count ≥ AIMD_TRIP_THRESHOLD → HALVE the previous effective cap
|
|
178
|
+
* (`Math.floor`), floored at 1 (never admit zero — a single slot keeps the
|
|
179
|
+
* queue making progress so the breaker can observe recovery).
|
|
180
|
+
* - Else (a clean pass) → ADDITIVELY restore +1, capped at `configuredCap`.
|
|
181
|
+
* - Always clamped to `[1, configuredCap]` (never below 1, never above the
|
|
182
|
+
* configured cap). A `configuredCap <= 0` returns 0 (a degenerate config
|
|
183
|
+
* admits nothing — mirrors planNextStarts).
|
|
184
|
+
*
|
|
185
|
+
* `lastCap` is the effective cap from the PREVIOUS pass (defaults to
|
|
186
|
+
* `configuredCap` on the first pass — we start fully open). `recentThrottles` is
|
|
187
|
+
* the list of throttle-event timestamps (ISO or ms) the conductor tracks.
|
|
188
|
+
*/
|
|
189
|
+
export function aimdEffectiveCap(configuredCap, recentThrottles, now = Date.now(), lastCap) {
|
|
190
|
+
if (configuredCap <= 0)
|
|
191
|
+
return 0;
|
|
192
|
+
const prev = lastCap === undefined
|
|
193
|
+
? configuredCap
|
|
194
|
+
: Math.min(configuredCap, Math.max(1, Math.trunc(lastCap)));
|
|
195
|
+
const windowStart = now - AIMD_WINDOW_MS;
|
|
196
|
+
const inWindow = recentThrottles.filter((t) => {
|
|
197
|
+
const ms = typeof t === "number" ? t : Date.parse(t);
|
|
198
|
+
return Number.isFinite(ms) && ms >= windowStart && ms <= now;
|
|
199
|
+
}).length;
|
|
200
|
+
let next;
|
|
201
|
+
if (inWindow >= AIMD_TRIP_THRESHOLD) {
|
|
202
|
+
next = Math.max(1, Math.floor(prev / 2)); // multiplicative decrease
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
next = prev + 1; // additive increase
|
|
206
|
+
}
|
|
207
|
+
// Clamp to [1, configuredCap].
|
|
208
|
+
if (next < 1)
|
|
209
|
+
next = 1;
|
|
210
|
+
if (next > configuredCap)
|
|
211
|
+
next = configuredCap;
|
|
212
|
+
return next;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Decide what to do with a HEADLESS LOCAL job whose latest finished run was
|
|
216
|
+
* classified as a transient rate-limit. PURE — no clock/registry/IO; the caller
|
|
217
|
+
* passes `now`, the prior `throttle` bookkeeping (undefined on the first hit),
|
|
218
|
+
* the matched `signature`, and a `delay` (a jittered backoff in ms, computed via
|
|
219
|
+
* `jitteredBackoffMs`).
|
|
220
|
+
*
|
|
221
|
+
* - First/next throttle while `attempts < maxAttempts`: bump `attempts`,
|
|
222
|
+
* preserve `firstAt`, schedule `nextRetryAt = now + delay`.
|
|
223
|
+
* - When the NEXT attempt would exceed `maxAttempts` (i.e. prior attempts already
|
|
224
|
+
* == maxAttempts): give up → `{action:"fail", reason:"rate-limited"}`.
|
|
225
|
+
*
|
|
226
|
+
* Exported for tests.
|
|
227
|
+
*/
|
|
228
|
+
export function planThrottleStep(opts) {
|
|
229
|
+
const max = opts.maxAttempts ?? THROTTLE_MAX_ATTEMPTS;
|
|
230
|
+
const priorAttempts = opts.prior?.attempts ?? 0;
|
|
231
|
+
if (priorAttempts >= max) {
|
|
232
|
+
return { action: "fail", reason: "rate-limited" };
|
|
233
|
+
}
|
|
234
|
+
const nowIso = new Date(opts.nowMs).toISOString();
|
|
235
|
+
const attempts = priorAttempts + 1;
|
|
236
|
+
return {
|
|
237
|
+
action: "throttle",
|
|
238
|
+
attempts,
|
|
239
|
+
firstAt: opts.prior?.firstAt ?? nowIso,
|
|
240
|
+
nextRetryAt: new Date(opts.nowMs + opts.delayMs).toISOString(),
|
|
241
|
+
...(opts.signature !== undefined ? { signature: opts.signature } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Is a `throttled` job DUE for resume right now (now >= nextRetryAt)? Pure,
|
|
246
|
+
* tolerant of a missing/unparseable `nextRetryAt` (treated as due — better to
|
|
247
|
+
* retry than to strand a job forever). Exported for tests.
|
|
248
|
+
*/
|
|
249
|
+
export function isThrottleResumeDue(throttle, nowMs) {
|
|
250
|
+
const at = throttle?.nextRetryAt;
|
|
251
|
+
if (at === undefined)
|
|
252
|
+
return true;
|
|
253
|
+
const ms = Date.parse(at);
|
|
254
|
+
if (!Number.isFinite(ms))
|
|
255
|
+
return true;
|
|
256
|
+
return nowMs >= ms;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Minutes-until-retry label for a `throttled` row (`jobs ls`), e.g. "retry in
|
|
260
|
+
* 3m" / "retry now". Pure, exported for tests. Rounds UP so "<1 min away" still
|
|
261
|
+
* reads as 1m rather than 0.
|
|
262
|
+
*/
|
|
263
|
+
export function throttleRetryLabel(throttle, nowMs = Date.now()) {
|
|
264
|
+
const at = throttle?.nextRetryAt;
|
|
265
|
+
if (at === undefined)
|
|
266
|
+
return "retry now";
|
|
267
|
+
const ms = Date.parse(at);
|
|
268
|
+
if (!Number.isFinite(ms) || ms <= nowMs)
|
|
269
|
+
return "retry now";
|
|
270
|
+
const mins = Math.ceil((ms - nowMs) / 60_000);
|
|
271
|
+
return `retry in ${mins}m`;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* P2 — REMOTE delegation. Map a (type, task) to the control-plane session body
|
|
275
|
+
* for a delegated agent running in a Pod. The agent CLI is the session PROFILE
|
|
276
|
+
* (profiles claude/codex/agy launch the matching binary on the Pod), and the
|
|
277
|
+
* task rides the ALREADY-SAFE argv channel `startupArgs` — exactly the args that
|
|
278
|
+
* follow the binary, which the session-agent reads from `SESSION_STARTUP_ARGS`
|
|
279
|
+
* (a JSON array, never a `bash -lc` string). We reuse `buildDelegateArgs` and
|
|
280
|
+
* DROP its `command` (the profile already provides the binary): `args` is what
|
|
281
|
+
* trails it (the bare task token interactive, `-p <task>` / `exec <task>`
|
|
282
|
+
* headless). Pure, exported for tests.
|
|
283
|
+
*
|
|
284
|
+
* SECURITY: the task stays a single argv element through `startupArgs` → JSON →
|
|
285
|
+
* the agent's argv; it is NEVER concatenated into a shell string.
|
|
286
|
+
*/
|
|
287
|
+
export function buildRemoteDelegate(type, task, headless, model, effort) {
|
|
288
|
+
const { args } = buildDelegateArgs(type, task, headless, model, effort);
|
|
289
|
+
return { profile: type, startupArgs: args };
|
|
290
|
+
}
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// P4 — concurrency cap + queue + spawn-depth (pure, conductor-driving)
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
/**
|
|
295
|
+
* Default concurrency cap (Hermes `max_concurrent_children`), applied to BOTH
|
|
296
|
+
* local AND remote jobs: the shared RWX volume is mounted RW across all nodes
|
|
297
|
+
* (subPath per job — no CSI packing limit, design F3 correction), so remote is
|
|
298
|
+
* not sequential. A new `delegate` beyond `running` jobs is enrolled `pending`.
|
|
299
|
+
*/
|
|
300
|
+
export const DEFAULT_MAX_CONCURRENT = 16;
|
|
301
|
+
/** Default / min / max spawn depth (Hermes `max_spawn_depth`, clamp 1–3). */
|
|
302
|
+
export const DEFAULT_MAX_DEPTH = 1;
|
|
303
|
+
export const MIN_MAX_DEPTH = 1;
|
|
304
|
+
export const MAX_MAX_DEPTH = 3;
|
|
305
|
+
/** The env channel carrying the REMAINING delegation depth into a job's agent. */
|
|
306
|
+
export const DEPTH_ENV = "REMOTE_DELEGATE_DEPTH";
|
|
307
|
+
/**
|
|
308
|
+
* Env channel carrying a delegated job's id INTO its agent process (H1). The
|
|
309
|
+
* local-tmux spawn stamps `REMOTE_JOB_ID=<jobId>` so the claude SessionStart/End
|
|
310
|
+
* hooks — which only receive claude's conversation uuid (`session_id`), never the
|
|
311
|
+
* job slug — can resolve the job and complete it. Without this, an interactive
|
|
312
|
+
* tmux job never advances past `running`.
|
|
313
|
+
*/
|
|
314
|
+
export const JOB_ID_ENV = "REMOTE_JOB_ID";
|
|
315
|
+
/**
|
|
316
|
+
* Clamp a requested `--max-depth` into [1, 3] (à la Hermes). A missing /
|
|
317
|
+
* non-finite value falls back to the default (1). Pure, exported for tests.
|
|
318
|
+
*/
|
|
319
|
+
export function clampDepth(requested) {
|
|
320
|
+
if (requested === undefined || !Number.isFinite(requested)) {
|
|
321
|
+
return DEFAULT_MAX_DEPTH;
|
|
322
|
+
}
|
|
323
|
+
const n = Math.trunc(requested);
|
|
324
|
+
if (n < MIN_MAX_DEPTH)
|
|
325
|
+
return MIN_MAX_DEPTH;
|
|
326
|
+
if (n > MAX_MAX_DEPTH)
|
|
327
|
+
return MAX_MAX_DEPTH;
|
|
328
|
+
return n;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The depth a job INHERITS from its parent, read off `REMOTE_DELEGATE_DEPTH`
|
|
332
|
+
* (decremented at each launch). When a `delegate` runs OUTSIDE a job (no env),
|
|
333
|
+
* the requested `--max-depth` is the budget. When it runs INSIDE a job, the
|
|
334
|
+
* inherited budget wins (a job cannot grant itself more than it was given).
|
|
335
|
+
* Pure, exported for tests; `env` is injectable.
|
|
336
|
+
*
|
|
337
|
+
* Returns the budget the CURRENT `delegate` may spend:
|
|
338
|
+
* - no env (top-level): clamp(requestedMaxDepth).
|
|
339
|
+
* - env present: the inherited value (already a remaining budget), clamped
|
|
340
|
+
* to [0, 3] — 0 means "this job may not delegate further".
|
|
341
|
+
*/
|
|
342
|
+
export function inheritedDepthBudget(requestedMaxDepth, env = process.env) {
|
|
343
|
+
const raw = env[DEPTH_ENV];
|
|
344
|
+
if (raw === undefined)
|
|
345
|
+
return clampDepth(requestedMaxDepth);
|
|
346
|
+
const parsed = Number.parseInt(raw, 10);
|
|
347
|
+
if (!Number.isFinite(parsed))
|
|
348
|
+
return clampDepth(requestedMaxDepth);
|
|
349
|
+
if (parsed < 0)
|
|
350
|
+
return 0;
|
|
351
|
+
if (parsed > MAX_MAX_DEPTH)
|
|
352
|
+
return MAX_MAX_DEPTH;
|
|
353
|
+
return parsed;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* May a `delegate` proceed given its current depth budget? A budget of 0 means
|
|
357
|
+
* the calling job has exhausted its delegation depth and must REFUSE. Pure.
|
|
358
|
+
*/
|
|
359
|
+
export function canDelegateAtDepth(budget) {
|
|
360
|
+
return budget > 0;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* REMOTE depth clamp (YAGNI/safety): there is NO env channel carrying
|
|
364
|
+
* `REMOTE_DELEGATE_DEPTH` into a Pod (`createRemoteSession` only passes
|
|
365
|
+
* `startupArgs`, and the session-agent doesn't forward a depth budget), so a
|
|
366
|
+
* remote job could NOT enforce a budget even if we recorded one > 1. Until that
|
|
367
|
+
* channel exists we clamp a REMOTE job's recorded budget to AT MOST 1 — i.e. a
|
|
368
|
+
* job-in-a-Pod does not re-delegate. (Local jobs DO inherit the budget via the
|
|
369
|
+
* env, so they keep the full 1–3 range.) Pure, exported for tests.
|
|
370
|
+
*/
|
|
371
|
+
export function clampRemoteDepthBudget(budget) {
|
|
372
|
+
return Math.min(budget, 1);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The depth value to PROPAGATE to a freshly launched job: the parent's budget
|
|
376
|
+
* minus one (never below 0), so the child knows how much further it may go.
|
|
377
|
+
* Returned as the string the env channel carries. Pure, exported for tests.
|
|
378
|
+
*/
|
|
379
|
+
export function childDepthEnvValue(parentBudget) {
|
|
380
|
+
return String(Math.max(0, parentBudget - 1));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Decide which `pending` jobs to START this pass, given the cap. Pure, the heart
|
|
384
|
+
* of the queue: count jobs currently occupying a slot (`running`), and admit
|
|
385
|
+
* `pending` jobs (oldest first — FIFO by `enrolledAt`) until `running` reaches
|
|
386
|
+
* the cap. Terminal jobs (done/failed) free their slot. Returns the ids to
|
|
387
|
+
* start, in admission order. Exported for tests.
|
|
388
|
+
*
|
|
389
|
+
* `cap <= 0` admits nothing (a degenerate config never starts work).
|
|
390
|
+
*/
|
|
391
|
+
export function planNextStarts(jobs, cap) {
|
|
392
|
+
if (cap <= 0)
|
|
393
|
+
return [];
|
|
394
|
+
const onlyJobs = jobs.filter((j) => j.role === "job");
|
|
395
|
+
// `running` AND `throttled` jobs occupy a slot (a throttled job keeps its slot
|
|
396
|
+
// — the rate-limit is account-wide; see registry.occupiesSlot).
|
|
397
|
+
const running = onlyJobs.filter((j) => occupiesSlot(j.jobState ?? "pending")).length;
|
|
398
|
+
let free = cap - running;
|
|
399
|
+
if (free <= 0)
|
|
400
|
+
return [];
|
|
401
|
+
const pending = onlyJobs
|
|
402
|
+
.filter((j) => (j.jobState ?? "pending") === "pending")
|
|
403
|
+
.slice()
|
|
404
|
+
.sort((a, b) => Date.parse(a.enrolledAt) - Date.parse(b.enrolledAt));
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const j of pending) {
|
|
407
|
+
if (free <= 0)
|
|
408
|
+
break;
|
|
409
|
+
out.push(j.id);
|
|
410
|
+
free -= 1;
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Is there room to start a job RIGHT NOW (used by `delegate` to decide launch vs
|
|
416
|
+
* enqueue)? True when `running < cap`. Pure, exported for tests.
|
|
417
|
+
*/
|
|
418
|
+
export function hasFreeSlot(jobs, cap) {
|
|
419
|
+
if (cap <= 0)
|
|
420
|
+
return false;
|
|
421
|
+
const running = jobs.filter((j) => j.role === "job" && occupiesSlot(j.jobState ?? "pending")).length;
|
|
422
|
+
return running < cap;
|
|
423
|
+
}
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// P4 — track mirror (job graph = backlog). PURE arg derivation; best-effort.
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
/**
|
|
428
|
+
* The `track item new` argv that mirrors a delegated job as a child item under
|
|
429
|
+
* the workpackage `wpId`. PURE (no spawn): the caller resolves the track binary
|
|
430
|
+
* (realpath) and runs it best-effort. `--role item`, parented under the WP, with
|
|
431
|
+
* a stable title derived from the job. Exported for tests.
|
|
432
|
+
*
|
|
433
|
+
* SECURITY: jobId is assertSafeName-checked at delegate time; wpId/task ride
|
|
434
|
+
* structured argv (never a shell string).
|
|
435
|
+
*/
|
|
436
|
+
export function trackItemNewArgs(wpId, job) {
|
|
437
|
+
const title = job.task && job.task.length > 0 ? job.task : `job ${job.id}`;
|
|
438
|
+
return [
|
|
439
|
+
"item",
|
|
440
|
+
"new",
|
|
441
|
+
"--parent",
|
|
442
|
+
wpId,
|
|
443
|
+
"--role",
|
|
444
|
+
"item",
|
|
445
|
+
"--title",
|
|
446
|
+
title,
|
|
447
|
+
"--ref",
|
|
448
|
+
`job:${job.id}`,
|
|
449
|
+
];
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The `track item realize` argv to close the mirror item when a job reaches a
|
|
453
|
+
* terminal state. We address the item by the same `job:<id>` ref we created it
|
|
454
|
+
* with. PURE; the caller runs it best-effort. Exported for tests.
|
|
455
|
+
*/
|
|
456
|
+
export function trackItemRealizeArgs(job) {
|
|
457
|
+
return ["item", "realize", "--ref", `job:${job.id}`];
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Resolve the `track` binary to its REALPATH (some track versions have an
|
|
461
|
+
* entrypoint guard that breaks through the npm-global bin symlink — same note as
|
|
462
|
+
* config.ts PluginMcp). Returns `node <realpath>` argv, or undefined when track
|
|
463
|
+
* is not installed. Best-effort, exported for tests (the `which`+realpath are
|
|
464
|
+
* injectable).
|
|
465
|
+
*/
|
|
466
|
+
export function resolveTrackBin(which = defaultWhich, realpath = (p) => p) {
|
|
467
|
+
const found = which("track");
|
|
468
|
+
if (!found)
|
|
469
|
+
return undefined;
|
|
470
|
+
try {
|
|
471
|
+
return { command: "node", prefix: [realpath(found)] };
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return { command: found, prefix: [] };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function defaultWhich(bin) {
|
|
478
|
+
const r = spawnSync("sh", ["-lc", `command -v ${bin}`], {
|
|
479
|
+
encoding: "utf8",
|
|
480
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
481
|
+
});
|
|
482
|
+
const out = r.status === 0 ? r.stdout.trim() : "";
|
|
483
|
+
return out.length > 0 ? out : undefined;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Best-effort track-mirror call: resolve `track`, run `track <args>` in `cwd`,
|
|
487
|
+
* swallow ANY failure (track absent/erroring must NEVER couple delivery to
|
|
488
|
+
* track). Returns whether it ran. The runner is injectable for tests.
|
|
489
|
+
*/
|
|
490
|
+
export function runTrackMirror(args, cwd, run) {
|
|
491
|
+
try {
|
|
492
|
+
if (run) {
|
|
493
|
+
run(args);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
const bin = resolveTrackBin();
|
|
497
|
+
if (!bin)
|
|
498
|
+
return false;
|
|
499
|
+
spawnSync(bin.command, [...bin.prefix, ...args], { cwd, stdio: "ignore" });
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Per-job directory under the ORIGIN cwd (where worktree + logs live). */
|
|
507
|
+
export function jobDir(originCwd, jobId) {
|
|
508
|
+
return join(originCwd, ".remote", "jobs", jobId);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The terminal result a HEADLESS job wrote to `result.json` (run-once-exit), or
|
|
512
|
+
* undefined when absent / unreadable. Lets supervision reconcile a finished
|
|
513
|
+
* headless job (whose tmux session has ended) to its REAL state instead of the
|
|
514
|
+
* default "session-gone → failed". Pure-ish (reads one file), injectable read.
|
|
515
|
+
*/
|
|
516
|
+
export function readJobResult(originCwd, jobId, read = (p) => readFileSync(p, "utf8")) {
|
|
517
|
+
const path = join(jobDir(originCwd, jobId), "result.json");
|
|
518
|
+
try {
|
|
519
|
+
const parsed = JSON.parse(read(path));
|
|
520
|
+
if ((parsed.state === "done" || parsed.state === "failed") &&
|
|
521
|
+
typeof parsed.exitCode === "number") {
|
|
522
|
+
return { state: parsed.state, exitCode: parsed.exitCode };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// missing / unreadable / malformed → no result yet
|
|
527
|
+
}
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
function defaultIsGitRepo(cwd) {
|
|
531
|
+
if (!existsSync(cwd))
|
|
532
|
+
return false;
|
|
533
|
+
const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
534
|
+
cwd,
|
|
535
|
+
encoding: "utf8",
|
|
536
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
537
|
+
});
|
|
538
|
+
return r.status === 0 && r.stdout.trim() === "true";
|
|
539
|
+
}
|
|
540
|
+
function defaultRunGit(cmd, args, cwd) {
|
|
541
|
+
const r = spawnSync(cmd, [...args], { cwd, stdio: "inherit" });
|
|
542
|
+
return { status: r.status };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Decide WHERE the job runs and create isolation if warranted:
|
|
546
|
+
* - `--cwd <path>` → that path, as-is (caller owns isolation).
|
|
547
|
+
* - else if the origin cwd is a git repo → a dedicated worktree at
|
|
548
|
+
* `<originCwd>/.remote/jobs/<jobId>/wt` (so concurrent jobs never clobber the
|
|
549
|
+
* working tree; the single-writer guard only protects the `.jsonl`).
|
|
550
|
+
* - else → the origin cwd, as-is (non-repo: nothing to isolate cheaply).
|
|
551
|
+
*
|
|
552
|
+
* Throws if the worktree creation fails (we must NOT silently run in the shared
|
|
553
|
+
* cwd, which would defeat isolation).
|
|
554
|
+
*/
|
|
555
|
+
export function resolveJobCwd(originCwd, jobId, opts = {}) {
|
|
556
|
+
if (opts.explicitCwd !== undefined) {
|
|
557
|
+
return { runCwd: opts.explicitCwd, isolated: false };
|
|
558
|
+
}
|
|
559
|
+
const isRepo = (opts.isGitRepo ?? defaultIsGitRepo)(originCwd);
|
|
560
|
+
if (!isRepo)
|
|
561
|
+
return { runCwd: originCwd, isolated: false };
|
|
562
|
+
const dir = jobDir(originCwd, jobId);
|
|
563
|
+
const wt = join(dir, "wt");
|
|
564
|
+
(opts.mkdir ?? ((p) => mkdirSync(p, { recursive: true })))(dir);
|
|
565
|
+
const run = opts.runGit ?? defaultRunGit;
|
|
566
|
+
// Detached worktree on current HEAD — a fresh, independent file tree.
|
|
567
|
+
const r = run("git", ["worktree", "add", "--detach", wt, "HEAD"], originCwd);
|
|
568
|
+
if (r.status !== 0) {
|
|
569
|
+
throw new Error(`git worktree add failed (exit ${r.status ?? "?"}) for job ${jobId} — ` +
|
|
570
|
+
`pass --cwd to run in an explicit directory instead`);
|
|
571
|
+
}
|
|
572
|
+
return { runCwd: wt, isolated: true };
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The DISPLAY state of a job, reconciling its persisted `jobState` with live
|
|
576
|
+
* liveness: a job still marked `running`/`pending` whose tmux session is gone
|
|
577
|
+
* has crashed/finished → show `failed` (P1 has no headless-success poll in the
|
|
578
|
+
* pure layer; the headless result.json is surfaced by `jobs status`). A job
|
|
579
|
+
* already in a terminal state keeps it. Pure, exported for tests.
|
|
580
|
+
*/
|
|
581
|
+
export function reconcileJobState(entry, live) {
|
|
582
|
+
const persisted = entry.jobState ?? "pending";
|
|
583
|
+
if (persisted === "done" || persisted === "failed")
|
|
584
|
+
return persisted;
|
|
585
|
+
// A `throttled` job has DELIBERATELY finished its run (its tmux session is gone
|
|
586
|
+
// — not live) and is waiting for the conductor's backoff resume. Its
|
|
587
|
+
// non-liveness is expected, NOT a crash → keep `throttled` (the resume path /
|
|
588
|
+
// attempt cap drives it terminal). Reliability slice 1.
|
|
589
|
+
if (persisted === "throttled")
|
|
590
|
+
return "throttled";
|
|
591
|
+
if (entry.endedAt)
|
|
592
|
+
return "failed";
|
|
593
|
+
if (!live)
|
|
594
|
+
return "failed";
|
|
595
|
+
return persisted;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* P2 — CLUSTER reconciliation for REMOTE jobs (`kind:"remote"`). The registry
|
|
599
|
+
* reports a remote entry as `isLive` ALWAYS (it can't probe the cluster —
|
|
600
|
+
* registry.ts), so a delegated Pod that died/finished would stay "running"
|
|
601
|
+
* forever. Given the set of session ids the control-plane still lists
|
|
602
|
+
* (`listRemoteSessions`), this decides the transitions to apply: a remote job
|
|
603
|
+
* still in a non-terminal state whose `remoteId` is NOT in the live set has
|
|
604
|
+
* ended → `done` when it left a `result.json` (success/known exit), else
|
|
605
|
+
* `failed`. Jobs whose Pod is still listed, or already terminal, are untouched.
|
|
606
|
+
* Pure (the result read is injected), exported for tests.
|
|
607
|
+
*/
|
|
608
|
+
export function reconcileRemoteJobs(jobs, liveRemoteIds, readResult) {
|
|
609
|
+
const out = [];
|
|
610
|
+
for (const job of jobs) {
|
|
611
|
+
if (job.kind !== "remote" || job.role !== "job")
|
|
612
|
+
continue;
|
|
613
|
+
const state = job.jobState ?? "pending";
|
|
614
|
+
if (state === "done" || state === "failed")
|
|
615
|
+
continue;
|
|
616
|
+
// The Pod is still listed by the control-plane → leave the job alone.
|
|
617
|
+
if (job.remoteId !== undefined && liveRemoteIds.has(job.remoteId))
|
|
618
|
+
continue;
|
|
619
|
+
const result = readResult(job);
|
|
620
|
+
out.push({ id: job.id, to: result?.state ?? "failed" });
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* M2 — SWEEP stale jobs. A job stuck in a non-terminal `running` /
|
|
626
|
+
* `awaiting-decision`-ish state (we only persist `running`; awaiting-decision is
|
|
627
|
+
* a DISPLAY overlay, so a job awaiting a decision is `running` on disk) whose
|
|
628
|
+
* runtime signal was lost — NOT live AND no `result.json` AND older than
|
|
629
|
+
* `maxAgeMs` — would otherwise occupy a concurrency slot forever. This returns
|
|
630
|
+
* the ids to transition to `failed`. Bounds BOTH plain running jobs and ones
|
|
631
|
+
* showing awaiting-decision (same persisted `running`). `pending` jobs are NOT
|
|
632
|
+
* swept here (they were never launched — the conductor owns them). Terminal jobs
|
|
633
|
+
* are skipped. Pure (liveness + result presence injected), exported for tests.
|
|
634
|
+
*/
|
|
635
|
+
export function sweepStaleJobs(jobs, opts) {
|
|
636
|
+
const now = opts.nowMs ?? Date.now();
|
|
637
|
+
const out = [];
|
|
638
|
+
for (const job of jobs) {
|
|
639
|
+
if (job.role !== "job")
|
|
640
|
+
continue;
|
|
641
|
+
const state = job.jobState ?? "pending";
|
|
642
|
+
if (state !== "running")
|
|
643
|
+
continue; // pending/terminal are not our concern
|
|
644
|
+
if (opts.isJobLive(job))
|
|
645
|
+
continue; // still alive → leave it
|
|
646
|
+
if (opts.hasResult(job))
|
|
647
|
+
continue; // headless finished → reconcile handles it
|
|
648
|
+
// Age from last activity (lastSeenAt), falling back to enrolledAt. Use
|
|
649
|
+
// Number.isFinite (NOT `||`) so a legitimate epoch-0 timestamp isn't treated
|
|
650
|
+
// as missing.
|
|
651
|
+
const seen = Date.parse(job.lastSeenAt);
|
|
652
|
+
const enrolled = Date.parse(job.enrolledAt);
|
|
653
|
+
const last = Number.isFinite(seen)
|
|
654
|
+
? seen
|
|
655
|
+
: Number.isFinite(enrolled)
|
|
656
|
+
? enrolled
|
|
657
|
+
: now;
|
|
658
|
+
if (now - last < opts.maxAgeMs)
|
|
659
|
+
continue; // not stale yet
|
|
660
|
+
out.push(job.id);
|
|
661
|
+
}
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Build `jobs ls` rows from job entries + an injectable liveness probe. Pure,
|
|
666
|
+
* exported for tests. `nowMs` makes the age deterministic.
|
|
667
|
+
*/
|
|
668
|
+
export function buildJobRows(jobs, isJobLive, nowMs = Date.now(), resolveAccountLabel) {
|
|
669
|
+
return jobs.map((e) => {
|
|
670
|
+
const state = reconcileJobState(e, isJobLive(e));
|
|
671
|
+
// A `throttled` row shows its backoff "retry in Xm" (derived from
|
|
672
|
+
// nextRetryAt) plus, when present, the matched signature. Reliability slice 1.
|
|
673
|
+
const note = state === "throttled"
|
|
674
|
+
? `${throttleRetryLabel(e.throttle, nowMs)}` +
|
|
675
|
+
(e.throttle?.attempts !== undefined ? ` (try ${e.throttle.attempts}/${THROTTLE_MAX_ATTEMPTS})` : "")
|
|
676
|
+
: "";
|
|
677
|
+
const row = {
|
|
678
|
+
id: e.id,
|
|
679
|
+
type: e.tool,
|
|
680
|
+
state,
|
|
681
|
+
age: humanAge(Date.parse(e.enrolledAt), nowMs),
|
|
682
|
+
cwd: e.cwd,
|
|
683
|
+
note,
|
|
684
|
+
};
|
|
685
|
+
if (e.remoteId)
|
|
686
|
+
row.session = e.remoteId;
|
|
687
|
+
if (resolveAccountLabel) {
|
|
688
|
+
const label = resolveAccountLabel(e.id);
|
|
689
|
+
if (label !== undefined)
|
|
690
|
+
row.account = label;
|
|
691
|
+
}
|
|
692
|
+
return row;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* M3 — advisory line for `jobs ls`: when there are QUEUED (`pending`) jobs but
|
|
697
|
+
* NO conductor running to drain them, warn the user (we do NOT self-heal — a
|
|
698
|
+
* one-shot `jobs ls` can't start the queue; only `jobs conduct` does). Returns
|
|
699
|
+
* undefined when there's nothing to warn about. Pure (the conductor-liveness
|
|
700
|
+
* probe is passed in), exported for tests.
|
|
701
|
+
*/
|
|
702
|
+
export function conductorAdvisory(jobs, conductorRunning) {
|
|
703
|
+
if (conductorRunning)
|
|
704
|
+
return undefined;
|
|
705
|
+
const pending = jobs.filter((j) => j.role === "job" && (j.jobState ?? "pending") === "pending").length;
|
|
706
|
+
if (pending === 0)
|
|
707
|
+
return undefined;
|
|
708
|
+
return (`[remote] ${pending} pending job(s) and no active conductor — they will NOT start on their own. ` +
|
|
709
|
+
`Run: remote jobs conduct (or remote jobs conduct --watch <min> to keep draining)`);
|
|
710
|
+
}
|
|
711
|
+
/** Render `jobs ls` rows as an aligned plain-text table. Pure. */
|
|
712
|
+
export function renderJobsTable(rows) {
|
|
713
|
+
if (rows.length === 0)
|
|
714
|
+
return "(no delegated jobs)";
|
|
715
|
+
const cols = [
|
|
716
|
+
["id", "ID"],
|
|
717
|
+
["type", "TYPE"],
|
|
718
|
+
["state", "STATE"],
|
|
719
|
+
["age", "AGE"],
|
|
720
|
+
["cwd", "CWD"],
|
|
721
|
+
];
|
|
722
|
+
// Show SESSION for remote jobs so `remote attach <session>` is copy-paste-ready.
|
|
723
|
+
if (rows.some((r) => r.session !== undefined))
|
|
724
|
+
cols.push(["session", "SESSION"]);
|
|
725
|
+
// Show ACCOUNT when any row has a pool binding.
|
|
726
|
+
if (rows.some((r) => r.account !== undefined))
|
|
727
|
+
cols.push(["account", "ACCOUNT"]);
|
|
728
|
+
// Only show the NOTE column when something needs it (a throttled "retry in Xm"
|
|
729
|
+
// hint) — keeps the common all-running table unchanged. Reliability slice 1.
|
|
730
|
+
if (rows.some((r) => r.note.length > 0))
|
|
731
|
+
cols.push(["note", "NOTE"]);
|
|
732
|
+
const widths = cols.map(([key, title]) => Math.max(title.length, ...rows.map((r) => String(r[key]).length)));
|
|
733
|
+
const line = (vals) => vals.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
|
|
734
|
+
const out = [line(cols.map(([, t]) => t))];
|
|
735
|
+
for (const r of rows)
|
|
736
|
+
out.push(line(cols.map(([k]) => String(r[k]))));
|
|
737
|
+
return out.join("\n");
|
|
738
|
+
}
|
|
739
|
+
//# sourceMappingURL=delegate.js.map
|