@sentropic/h2a-runtime 0.88.0 → 0.89.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/attach.d.ts +21 -0
- package/dist/attach.d.ts.map +1 -1
- package/dist/attach.js +69 -1
- package/dist/attach.js.map +1 -1
- package/dist/file-lock.d.ts +34 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +96 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +289 -18
- package/dist/index.js.map +1 -1
- package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/accounts.js +3 -0
- package/dist/llm-gateway-runtime/accounts.js.map +1 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.js +24 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -1
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/proxy-openai.js +53 -2
- package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -1
- package/dist/proc-cpu.d.ts +55 -0
- package/dist/proc-cpu.d.ts.map +1 -0
- package/dist/proc-cpu.js +133 -0
- package/dist/proc-cpu.js.map +1 -0
- package/dist/prompt-delivery.d.ts +159 -0
- package/dist/prompt-delivery.d.ts.map +1 -0
- package/dist/prompt-delivery.js +469 -0
- package/dist/prompt-delivery.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +7 -69
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts +20 -4
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +26 -4
- package/dist/restore.js.map +1 -1
- package/dist/session-lease.d.ts +266 -0
- package/dist/session-lease.d.ts.map +1 -0
- package/dist/session-lease.js +342 -0
- package/dist/session-lease.js.map +1 -0
- package/dist/tmux.d.ts +77 -2
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +269 -23
- package/dist/tmux.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,15 +15,17 @@ import { transmittedSecrets, secretsSummary } from "./secrets.js";
|
|
|
15
15
|
import { localConvStat, remoteConvStat, alignment } from "./convsync.js";
|
|
16
16
|
import { gitAlignment, localAncestry, localGitStat, remoteGitStat, } from "./gitdiff.js";
|
|
17
17
|
import { syncConversation } from "./sync.js";
|
|
18
|
-
import { attachLocalSession, attachPodTmux, capturePane, conductorRunning, currentTmuxSessionName, currentTmuxSessionIs, ensureManagedTmuxProfile, existingLocalSessionSlugs, fanoutLabels, findLocalSession, killLocalSession, localSessionGatewayEnvStatus, listLocalSessions, localSessionIdle, localSessionPanePid, localSessionName, installH2aStatusSurface, managedSessionCandidates, parseManagedSessionName, openH2aStatusWindow, persistLaunchContext, readLaunchContext, relaunchInSession, resolveAgentPaneForInstance, resolveLocalSession, runLocalCliForeground, sendKeysLiteral, sessionAttached, sessionAttachedCount, setLocalSessionDisplayName, slugify, cleanupHeadlessPromptFile, startH2aWindow, startH2aWindowVerified, startHeadlessSession, startLocalSession, tmuxAvailable, uninstallH2aStatusSurface, } from "./tmux.js";
|
|
18
|
+
import { attachLocalSession, attachPodTmux, capturePane, capturePaneVisible, clearPaneComposer, conductorRunning, currentTmuxSessionName, currentTmuxSessionIs, ensureManagedTmuxProfile, ensureHeadlessTerminal, existingLocalSessionSlugs, fanoutLabels, findLocalSession, killLocalSession, localSessionGatewayEnvStatus, listLocalSessions, localSessionIdle, localSessionPanePid, localSessionName, paneTreeCpuMs, paneWorkerPid, pasteLiteralBlock, submitPane, installH2aStatusSurface, managedSessionCandidates, parseManagedSessionName, openH2aStatusWindow, persistLaunchContext, readLaunchContext, relaunchInSession, resolveAgentPane, resolveAgentPaneForInstance, resolveLocalSession, runLocalCliForeground, sendKeysLiteral, sessionAttached, sessionAttachedCount, setLocalSessionDisplayName, slugify, cleanupHeadlessPromptFile, startH2aWindow, startH2aWindowVerified, startHeadlessSession, startLocalSession, tmuxAvailable, uninstallH2aStatusSurface, } from "./tmux.js";
|
|
19
|
+
import { deliverInitialPrompt, sleepSync, } from "./prompt-delivery.js";
|
|
19
20
|
import { buildLaunchContext } from "./launch-context.js";
|
|
20
21
|
import { migrateTmuxNames } from "./tmux-name-migration.js";
|
|
21
22
|
import { projectStatusForH2a } from "./status-projection.js";
|
|
22
23
|
import { buildAgentLaunchArgs, isAgentLaunchEffort, isAgentLaunchProfile, } from "./agent-launch-args.js";
|
|
23
24
|
import { planRelaunch } from "./relaunch.js";
|
|
24
|
-
import { readLastLayout, restore as restoreLayout, } from "./restore.js";
|
|
25
|
+
import { isHumanFacingSession, readLastLayout, restore as restoreLayout, } from "./restore.js";
|
|
25
26
|
import { getLayoutConfig } from "./config.js";
|
|
26
27
|
import { advanceJob, coerceRegistryTool, enroll, enrollFromRun, isLive, listJobs, listLocalForLs, loadRegistry, resolveLocalTmuxSessionForName, tryClaimSlot, withRegistryLock, resolveRegistryPath, } from "./registry.js";
|
|
28
|
+
import { SessionLeaseStore, decideSessionBeat, isSessionLeaseAbandoned, planLeaseSupervision, reclaimProposals, resolveSessionLeasePath, } from "./session-lease.js";
|
|
27
29
|
import { aimdEffectiveCap, assertDelegateEffort, assertSafeName, buildDelegateArgs, buildJobRows, buildRemoteDelegate, buildThrottleResumeArgs, canDelegateAtDepth, childDepthEnvValue, clampDepth, clampRemoteDepthBudget, conductorAdvisory, DEFAULT_MAX_CONCURRENT, DEPTH_ENV, inheritedDepthBudget, isDelegateType, isThrottleResumeDue, jitteredBackoffMs, JOB_ID_ENV, jobDir, planNextStarts, planThrottleStep, readJobResult, THROTTLE_MAX_ATTEMPTS, reconcileRemoteJobs, renderJobsTable, resolveJobCwd, runTrackMirror, sweepStaleJobs, trackItemNewArgs, trackItemRealizeArgs, } from "./delegate.js";
|
|
28
30
|
import { detectThrottle, THROTTLE_TAIL_LINES } from "./throttle-signatures.js";
|
|
29
31
|
import { interactiveResumeNudge, planInteractiveResume, } from "./interactive-throttle.js";
|
|
@@ -905,18 +907,21 @@ export async function startJob(job) {
|
|
|
905
907
|
const restoreGatewayEnvironment = replaceAnthropicGatewayEnvironment(process.env, meshEnv ?? undefined);
|
|
906
908
|
let tmuxSession;
|
|
907
909
|
let workerPid;
|
|
910
|
+
let agentPane;
|
|
908
911
|
try {
|
|
909
912
|
if (headless) {
|
|
910
913
|
const dir = jobDir(originCwd, job.id);
|
|
911
914
|
const launched = startHeadlessSession(job.tool, argv.command, runCwd, argv.args, join(dir, "result.json"), join(dir, "output.log"), job.id, undefined, undefined, false, "background");
|
|
912
915
|
tmuxSession = launched.name;
|
|
916
|
+
agentPane = launched.agentPane;
|
|
913
917
|
workerPid = launched.agentPane
|
|
914
918
|
? localSessionPanePid(launched.agentPane)
|
|
915
919
|
: undefined;
|
|
916
920
|
}
|
|
917
921
|
else {
|
|
918
|
-
const launched = startLocalSession(job.tool, argv.command, runCwd, argv.args, job.id, undefined, { sessionClass: "background" });
|
|
922
|
+
const launched = startLocalSession(job.tool, argv.command, runCwd, argv.args, job.id, undefined, { sessionClass: "background", attachedTerminal: true });
|
|
919
923
|
tmuxSession = launched.name;
|
|
924
|
+
agentPane = launched.agentPane;
|
|
920
925
|
workerPid = launched.agentPane
|
|
921
926
|
? localSessionPanePid(launched.agentPane)
|
|
922
927
|
: undefined;
|
|
@@ -971,6 +976,21 @@ export async function startJob(job) {
|
|
|
971
976
|
? { delegatorTmuxSession: job.delegatorTmuxSession }
|
|
972
977
|
: {}),
|
|
973
978
|
});
|
|
979
|
+
// Take the SLOT lease for this session (machine-scoped). From here on the slot
|
|
980
|
+
// is held only as long as something keeps beating it — a session that finishes
|
|
981
|
+
// without dying stops beating and its slot is reported reclaimable after one
|
|
982
|
+
// TTL, instead of the 994 minutes measured on the worst hoarder. Best-effort:
|
|
983
|
+
// a lease hiccup must never break a launch that already happened.
|
|
984
|
+
try {
|
|
985
|
+
acquireSessionLease(job.id, {
|
|
986
|
+
holder: job.delegatorInstance ?? "h2a:startJob",
|
|
987
|
+
workspace: runCwd,
|
|
988
|
+
...(agentPane !== undefined ? { agentPane } : {}),
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// the store is advisory — the job is running either way
|
|
993
|
+
}
|
|
974
994
|
mirrorNew();
|
|
975
995
|
return {
|
|
976
996
|
started: true,
|
|
@@ -978,6 +998,91 @@ export async function startJob(job) {
|
|
|
978
998
|
detail: `${runCwd}${isolated ? " [worktree]" : ""}`,
|
|
979
999
|
};
|
|
980
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Open the machine-scoped session-lease store. The clock is injected HERE, at
|
|
1003
|
+
* the call site, and never read inside the store: abandonment stays a reader's
|
|
1004
|
+
* computation against an instant the caller names.
|
|
1005
|
+
*/
|
|
1006
|
+
function sessionLeaseStore() {
|
|
1007
|
+
return new SessionLeaseStore(resolveSessionLeasePath(), {
|
|
1008
|
+
now: () => new Date().toISOString(),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Take the slot lease for a freshly launched session, seeding it with the pane's
|
|
1013
|
+
* current process-tree CPU so the FIRST supervising pass already has a baseline
|
|
1014
|
+
* to compute a rate against.
|
|
1015
|
+
*/
|
|
1016
|
+
export function acquireSessionLease(sessionId, opts) {
|
|
1017
|
+
const cpuMs = opts.agentPane ? paneTreeCpuMs(opts.agentPane) : undefined;
|
|
1018
|
+
return sessionLeaseStore().acquire({
|
|
1019
|
+
sessionId,
|
|
1020
|
+
holder: opts.holder,
|
|
1021
|
+
...(opts.workspace !== undefined ? { workspace: opts.workspace } : {}),
|
|
1022
|
+
...(cpuMs !== undefined ? { cpuMs } : {}),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* One supervising pass: for every recorded lease, decide from the REGISTRY what
|
|
1027
|
+
* to do, then — for sessions still running — probe the pane's process tree and
|
|
1028
|
+
* beat only what is actually working. Everything else merely records its CPU
|
|
1029
|
+
* reading, so the abandonment clock keeps running.
|
|
1030
|
+
*
|
|
1031
|
+
* This pass NEVER kills, never sends a key, and never frees a slot on its own.
|
|
1032
|
+
* Its only output beyond the store is a list of PROPOSALS.
|
|
1033
|
+
*/
|
|
1034
|
+
export function superviseSessionLeases(sessions, now = new Date().toISOString()) {
|
|
1035
|
+
const store = sessionLeaseStore();
|
|
1036
|
+
const result = {
|
|
1037
|
+
beaten: [],
|
|
1038
|
+
observed: [],
|
|
1039
|
+
released: [],
|
|
1040
|
+
unreadable: [],
|
|
1041
|
+
proposals: [],
|
|
1042
|
+
};
|
|
1043
|
+
const leases = store.readAll();
|
|
1044
|
+
const byId = new Map(leases.map((lease) => [lease.sessionId, lease]));
|
|
1045
|
+
for (const step of planLeaseSupervision(leases, sessions)) {
|
|
1046
|
+
const lease = byId.get(step.sessionId);
|
|
1047
|
+
if (lease === undefined)
|
|
1048
|
+
continue;
|
|
1049
|
+
try {
|
|
1050
|
+
if (step.action === "release") {
|
|
1051
|
+
store.release({ sessionId: lease.sessionId, token: lease.token });
|
|
1052
|
+
result.released.push(lease.sessionId);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (step.action === "leave")
|
|
1056
|
+
continue;
|
|
1057
|
+
const pane = step.tmuxSession ? resolveAgentPane(step.tmuxSession) : undefined;
|
|
1058
|
+
const cpuMsNow = pane ? paneTreeCpuMs(pane) : undefined;
|
|
1059
|
+
const decision = decideSessionBeat({ lease, cpuMsNow, now });
|
|
1060
|
+
if (decision.action === "beat" && cpuMsNow !== undefined) {
|
|
1061
|
+
store.heartbeat({ sessionId: lease.sessionId, token: lease.token, cpuMs: cpuMsNow });
|
|
1062
|
+
result.beaten.push(lease.sessionId);
|
|
1063
|
+
}
|
|
1064
|
+
else if (decision.action === "observe" && cpuMsNow !== undefined) {
|
|
1065
|
+
store.observe({ sessionId: lease.sessionId, token: lease.token, cpuMs: cpuMsNow });
|
|
1066
|
+
result.observed.push(lease.sessionId);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
result.unreadable.push(lease.sessionId);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
catch {
|
|
1073
|
+
// a rejected write (raced re-acquisition, torn store) must not stop the pass
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
result.proposals = reclaimProposals(store.readAll(), now);
|
|
1077
|
+
return result;
|
|
1078
|
+
}
|
|
1079
|
+
/** Human-readable duration for lease reporting ("41m", "2h13m"). */
|
|
1080
|
+
function formatIdle(ms) {
|
|
1081
|
+
const minutes = Math.floor(ms / 60_000);
|
|
1082
|
+
if (minutes < 60)
|
|
1083
|
+
return `${minutes}m`;
|
|
1084
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
1085
|
+
}
|
|
981
1086
|
/**
|
|
982
1087
|
* Reliability slice 1 — RESUME a throttled HEADLESS LOCAL job. Relaunch the SAME
|
|
983
1088
|
* job in the SAME `runCwd` it already ran in (its recorded `cwd` — NOT a fresh
|
|
@@ -1327,12 +1432,18 @@ function registryEntryForResumeTarget(target, local) {
|
|
|
1327
1432
|
? [target]
|
|
1328
1433
|
: managedSessionCandidates(canonicalSlug);
|
|
1329
1434
|
const matches = loadRegistry().filter((e) => {
|
|
1330
|
-
// Resume is a human-facing operation, not a promotion path
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1435
|
+
// Resume is a human-facing operation, not a promotion path: a delegated job
|
|
1436
|
+
// or an explicit background launch must never become resumable here.
|
|
1437
|
+
//
|
|
1438
|
+
// The class test is SHARED with restore rather than written inline, because
|
|
1439
|
+
// written inline it was an opt-in on an explicit class and every record
|
|
1440
|
+
// enrolled before the class existed failed closed — measured as "cannot
|
|
1441
|
+
// resume <slug>: registry has no human session" on entries that are plainly
|
|
1442
|
+
// human sessions. Two gates judging the same question in two ways is how one
|
|
1443
|
+
// of them ends up wrong on its own.
|
|
1444
|
+
if (e.role !== undefined || e.kind !== "local-tmux")
|
|
1445
|
+
return false;
|
|
1446
|
+
if (!isHumanFacingSession(e))
|
|
1336
1447
|
return false;
|
|
1337
1448
|
// Full managed names are exact targets; never reinterpret one as an id
|
|
1338
1449
|
// or label that happens to share a prefix-shaped string.
|
|
@@ -3408,7 +3519,7 @@ export async function main(argv) {
|
|
|
3408
3519
|
}
|
|
3409
3520
|
}
|
|
3410
3521
|
gateway = await injectLlmMeshGatewayEnv(effectiveGatewayMode, true, localSessionName(resumeSlug));
|
|
3411
|
-
const { name } = startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass: "human" });
|
|
3522
|
+
const { name } = startLocalSession(profile, command, entry.cwd, args, resumeSlug, undefined, { sessionClass: "human", attachedTerminal: true });
|
|
3412
3523
|
enrollFromRun({
|
|
3413
3524
|
profile,
|
|
3414
3525
|
slug: resumeSlug,
|
|
@@ -3432,6 +3543,7 @@ export async function main(argv) {
|
|
|
3432
3543
|
.command("run <profile> [path]")
|
|
3433
3544
|
.description("Start a LOCAL session in tmux (claude/codex/…) in <path> (default: cwd), then attach this terminal by default. h2a applies its embedded scroll-safe tmux profile at launch; no ~/.tmux.conf is required. Manage it with `h2a ls`, `h2a attach <slug>`, and `h2a stop <slug>`. Detach with Ctrl-b d; the session keeps running.")
|
|
3434
3545
|
.option("--no-attach", "start detached and print the attach command instead (for scripts / fan-out orchestration)")
|
|
3546
|
+
.option("--no-attached-terminal", "disable the persistent headless tmux client (legacy compatibility; cannot be used with --prompt-stdin)")
|
|
3435
3547
|
.option("-r, --resume <convId>", "resume a specific conversation in the CLI")
|
|
3436
3548
|
.option("--force", "start even if the conversation already has a live writer (risk: conversation .jsonl corruption)")
|
|
3437
3549
|
.option("--name <label>", "tmux session slug + tab label (default: workdir basename); use to keep multiple sessions of one project distinct")
|
|
@@ -3485,6 +3597,11 @@ export async function main(argv) {
|
|
|
3485
3597
|
process.exitCode = 2;
|
|
3486
3598
|
return;
|
|
3487
3599
|
}
|
|
3600
|
+
if (opts.promptStdin && opts.attachedTerminal === false) {
|
|
3601
|
+
process.stderr.write("[h2a] --prompt-stdin requires the attached terminal; remove --no-attached-terminal.\n");
|
|
3602
|
+
process.exitCode = 2;
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3488
3605
|
if (!tmuxAvailable()) {
|
|
3489
3606
|
process.stderr.write("[h2a] tmux is not installed locally — `h2a run` needs it (e.g. `sudo apt install tmux`).\n");
|
|
3490
3607
|
process.exitCode = 1;
|
|
@@ -3673,6 +3790,7 @@ export async function main(argv) {
|
|
|
3673
3790
|
let resultJson;
|
|
3674
3791
|
let agentPane;
|
|
3675
3792
|
let promptFile;
|
|
3793
|
+
let promptDelivery;
|
|
3676
3794
|
if (opts.headless) {
|
|
3677
3795
|
const runDir = join(cwd, ".h2a", "runs", label);
|
|
3678
3796
|
mkdirSync(runDir, { recursive: true });
|
|
@@ -3690,6 +3808,7 @@ export async function main(argv) {
|
|
|
3690
3808
|
? { terminateOnAgentExit: true }
|
|
3691
3809
|
: {}),
|
|
3692
3810
|
...(structuredLaunch ? { refuseExisting: true } : {}),
|
|
3811
|
+
attachedTerminal: opts.attachedTerminal !== false,
|
|
3693
3812
|
sessionClass,
|
|
3694
3813
|
}));
|
|
3695
3814
|
}
|
|
@@ -3725,18 +3844,54 @@ export async function main(argv) {
|
|
|
3725
3844
|
process.stderr.write(`[h2a] h2a window started in ${slug} (${h2a.command})\n`);
|
|
3726
3845
|
}
|
|
3727
3846
|
}
|
|
3728
|
-
if (!opts.headless &&
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3847
|
+
if (!opts.headless && initialPrompt !== undefined) {
|
|
3848
|
+
// NEVER paste-and-hope: `tmux new-session -d` returns before the
|
|
3849
|
+
// agent TUI draws anything, and a prompt sent into that window is
|
|
3850
|
+
// echoed by the raw terminal and then discarded. Delivery is
|
|
3851
|
+
// OBSERVED — brief seen in a real composer, then real work — because
|
|
3852
|
+
// reporting "started" over a lost brief is what left a lane inert
|
|
3853
|
+
// for 33 minutes on 2026-07-28.
|
|
3854
|
+
promptDelivery = deliverInitialPrompt(agentPane, initialPrompt, {
|
|
3855
|
+
capturePane: capturePaneVisible,
|
|
3856
|
+
clearComposer: clearPaneComposer,
|
|
3857
|
+
pasteBlock: pasteLiteralBlock,
|
|
3858
|
+
submit: submitPane,
|
|
3859
|
+
cpuMs: paneTreeCpuMs,
|
|
3860
|
+
sleep: sleepSync,
|
|
3861
|
+
now: () => Date.now(),
|
|
3862
|
+
});
|
|
3863
|
+
if (promptDelivery.state !== "working") {
|
|
3864
|
+
cleanupHeadlessPromptFile(promptFile);
|
|
3865
|
+
// Only claim the session is gone when the kill actually reported
|
|
3866
|
+
// success. "nothing is left running" over an unchecked call is the
|
|
3867
|
+
// same wider-than-its-evidence claim this change exists to remove,
|
|
3868
|
+
// and a surviving partial session is precisely the idle lane the
|
|
3869
|
+
// operator was just told could not happen.
|
|
3870
|
+
const stopped = killLocalSession(name);
|
|
3871
|
+
const detail = promptDelivery.state === "submitted-idle"
|
|
3872
|
+
? `the brief was submitted but the agent never started working ` +
|
|
3873
|
+
`(${Math.round(promptDelivery.cpuDeltaMs)}ms of CPU): it is not "started"`
|
|
3874
|
+
: promptDelivery.reason;
|
|
3875
|
+
process.stderr.write(`[h2a] the initial prompt did not reach ${slug}: ${detail}\n` +
|
|
3876
|
+
(promptDelivery.state === "host-modal"
|
|
3877
|
+
? `[h2a] fix: ${promptDelivery.hint}\n`
|
|
3878
|
+
: "") +
|
|
3879
|
+
(stopped
|
|
3880
|
+
? `[h2a] the partial session was stopped, so nothing is left running idle\n`
|
|
3881
|
+
: `[h2a] WARNING: stopping the partial session FAILED — ${name} may still be alive; ` +
|
|
3882
|
+
`list it with h2a ls and stop it with h2a stop ${slug} --reason failed-launch\n`) +
|
|
3883
|
+
(promptDelivery.state !== "submitted-idle" &&
|
|
3884
|
+
promptDelivery.capture
|
|
3885
|
+
? `[h2a] last screen:\n${promptDelivery.capture}\n`
|
|
3886
|
+
: ""));
|
|
3887
|
+
process.exitCode = 1;
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3736
3890
|
}
|
|
3737
3891
|
const pid = agentPane
|
|
3738
3892
|
? localSessionPanePid(agentPane)
|
|
3739
3893
|
: undefined;
|
|
3894
|
+
const workerPid = agentPane !== undefined ? paneWorkerPid(agentPane) : undefined;
|
|
3740
3895
|
if (structuredLaunch && pid === undefined) {
|
|
3741
3896
|
cleanupHeadlessPromptFile(promptFile);
|
|
3742
3897
|
killLocalSession(name);
|
|
@@ -3752,6 +3907,7 @@ export async function main(argv) {
|
|
|
3752
3907
|
slug,
|
|
3753
3908
|
tmuxSession: name,
|
|
3754
3909
|
...(pid !== undefined ? { pid } : {}),
|
|
3910
|
+
...(workerPid !== undefined ? { workerPid } : {}),
|
|
3755
3911
|
cwd,
|
|
3756
3912
|
sessionClass,
|
|
3757
3913
|
...(opts.resume !== undefined ? { convId: opts.resume } : {}),
|
|
@@ -3765,6 +3921,7 @@ export async function main(argv) {
|
|
|
3765
3921
|
...(pid !== undefined ? { pid } : {}),
|
|
3766
3922
|
...(outputLog !== undefined ? { outputLog } : {}),
|
|
3767
3923
|
...(resultJson !== undefined ? { resultJson } : {}),
|
|
3924
|
+
...(promptDelivery !== undefined ? { promptDelivery } : {}),
|
|
3768
3925
|
});
|
|
3769
3926
|
}
|
|
3770
3927
|
if (count > 1) {
|
|
@@ -3792,10 +3949,28 @@ export async function main(argv) {
|
|
|
3792
3949
|
h2aSidecar: only.h2aSidecar,
|
|
3793
3950
|
...(only.pane !== undefined ? { pane: only.pane } : {}),
|
|
3794
3951
|
...(only.pid !== undefined ? { pid: only.pid } : {}),
|
|
3952
|
+
// `pid` is the launch WRAPPER. Measured 2026-07-29: it showed 0s
|
|
3953
|
+
// of CPU after 37s while its codex child had burned 13s, so a
|
|
3954
|
+
// liveness check on `pid` alone reports a working agent as dead.
|
|
3955
|
+
// Name the process that actually works, and say it is distinct.
|
|
3956
|
+
...(only.workerPid !== undefined
|
|
3957
|
+
? { workerPid: only.workerPid }
|
|
3958
|
+
: {}),
|
|
3795
3959
|
},
|
|
3796
3960
|
attach: opts.headless
|
|
3797
3961
|
? null
|
|
3798
3962
|
: { command: "h2a", args: ["attach", only.slug] },
|
|
3963
|
+
// What was OBSERVED, not what was attempted: the brief was seen in
|
|
3964
|
+
// a real composer, submitted, and the agent's CPU then moved.
|
|
3965
|
+
...(only.promptDelivery?.state === "working"
|
|
3966
|
+
? {
|
|
3967
|
+
prompt: {
|
|
3968
|
+
delivered: true,
|
|
3969
|
+
waitedMs: only.promptDelivery.waitedMs,
|
|
3970
|
+
cpuDeltaMs: Math.round(only.promptDelivery.cpuDeltaMs),
|
|
3971
|
+
},
|
|
3972
|
+
}
|
|
3973
|
+
: {}),
|
|
3799
3974
|
...(only.outputLog !== undefined
|
|
3800
3975
|
? {
|
|
3801
3976
|
logs: { path: only.outputLog },
|
|
@@ -4358,6 +4533,74 @@ export async function main(argv) {
|
|
|
4358
4533
|
if (claudeWarn)
|
|
4359
4534
|
process.stderr.write(`${claudeWarn}\n`);
|
|
4360
4535
|
});
|
|
4536
|
+
jobsCommand
|
|
4537
|
+
.command("leases")
|
|
4538
|
+
.description("Session slot leases (machine-scoped): who holds a concurrency slot, when it last proved it was working, and which slots are reclaimable. READ-ONLY — it kills nothing and frees nothing.")
|
|
4539
|
+
.option("--probe", "before reporting, run one supervising pass: beat the leases of sessions whose pane is burning CPU, record the others")
|
|
4540
|
+
.action((opts) => {
|
|
4541
|
+
const now = new Date().toISOString();
|
|
4542
|
+
if (opts.probe === true)
|
|
4543
|
+
superviseSessionLeases(listJobs(), now);
|
|
4544
|
+
const leases = sessionLeaseStore().readAll();
|
|
4545
|
+
if (leases.length === 0) {
|
|
4546
|
+
process.stdout.write("no session leases\n");
|
|
4547
|
+
return;
|
|
4548
|
+
}
|
|
4549
|
+
const nowMs = Date.parse(now);
|
|
4550
|
+
const rows = leases
|
|
4551
|
+
.slice()
|
|
4552
|
+
.sort((a, b) => Date.parse(a.heartbeatAt) - Date.parse(b.heartbeatAt))
|
|
4553
|
+
.map((lease) => {
|
|
4554
|
+
const idle = nowMs - Date.parse(lease.heartbeatAt);
|
|
4555
|
+
const state = isSessionLeaseAbandoned(lease, now) ? "RECLAIMABLE" : "held";
|
|
4556
|
+
return (`${lease.sessionId.padEnd(24)} ${state.padEnd(12)} ` +
|
|
4557
|
+
`idle ${formatIdle(idle).padEnd(7)} ttl ${formatIdle(lease.ttlMs).padEnd(6)} ` +
|
|
4558
|
+
`holder ${lease.holder}`);
|
|
4559
|
+
});
|
|
4560
|
+
process.stdout.write(`${rows.join("\n")}\n`);
|
|
4561
|
+
const reclaimable = reclaimProposals(leases, now).length;
|
|
4562
|
+
if (reclaimable > 0) {
|
|
4563
|
+
process.stderr.write(`[h2a] ${reclaimable} slot(s) reclaimable — free one with: h2a jobs reclaim <id> ` +
|
|
4564
|
+
"(the session's tmux window is left running and can still be attached)\n");
|
|
4565
|
+
}
|
|
4566
|
+
});
|
|
4567
|
+
jobsCommand
|
|
4568
|
+
.command("reclaim <id>")
|
|
4569
|
+
.description("Free the concurrency SLOT of a session that stopped working (its lease is abandoned): the job is marked terminal and its lease released. It does NOT kill anything — the tmux session keeps running and stays attachable. Refuses while the lease is still being beaten.")
|
|
4570
|
+
.action((id) => {
|
|
4571
|
+
const now = new Date().toISOString();
|
|
4572
|
+
const store = sessionLeaseStore();
|
|
4573
|
+
const lease = store.forSession(id);
|
|
4574
|
+
if (!lease) {
|
|
4575
|
+
process.stderr.write(`[h2a] no session lease for "${id}" — nothing to reclaim (see: h2a jobs leases)\n`);
|
|
4576
|
+
process.exitCode = 1;
|
|
4577
|
+
return;
|
|
4578
|
+
}
|
|
4579
|
+
// The one guard that matters: a session still proving it works keeps its
|
|
4580
|
+
// slot, whatever the operator believes. Reclaiming is not arbitration.
|
|
4581
|
+
if (!isSessionLeaseAbandoned(lease, now)) {
|
|
4582
|
+
const idle = Date.parse(now) - Date.parse(lease.heartbeatAt);
|
|
4583
|
+
process.stderr.write(`[h2a] refusing to reclaim ${id}: its lease still beats (last proof of work ` +
|
|
4584
|
+
`${formatIdle(idle)} ago, TTL ${formatIdle(lease.ttlMs)}). It is working.\n`);
|
|
4585
|
+
process.exitCode = 1;
|
|
4586
|
+
return;
|
|
4587
|
+
}
|
|
4588
|
+
const job = listJobs().find((e) => e.id === id);
|
|
4589
|
+
const advanced = job ? advanceJob(id, "failed") : undefined;
|
|
4590
|
+
try {
|
|
4591
|
+
store.release({ sessionId: id, token: lease.token });
|
|
4592
|
+
}
|
|
4593
|
+
catch (err) {
|
|
4594
|
+
process.stderr.write(`[h2a] lease release failed: ${err.message}\n`);
|
|
4595
|
+
}
|
|
4596
|
+
process.stdout.write(`reclaimed ${id} — slot freed after ` +
|
|
4597
|
+
`${formatIdle(Date.parse(now) - Date.parse(lease.heartbeatAt))} without work\n`);
|
|
4598
|
+
process.stderr.write(`[h2a] the session was NOT killed: ${job?.tmuxSession ?? "its tmux session"} is still running ` +
|
|
4599
|
+
`(h2a jobs attach ${id}). ` +
|
|
4600
|
+
(advanced === undefined
|
|
4601
|
+
? "No registry entry to advance — only the lease was released.\n"
|
|
4602
|
+
: "The job is recorded terminal so the conductor can admit queued work.\n"));
|
|
4603
|
+
});
|
|
4361
4604
|
jobsCommand
|
|
4362
4605
|
.command("status <id>")
|
|
4363
4606
|
.description("Show a job's detail (+ output.log / result.json paths for headless jobs).")
|
|
@@ -4638,6 +4881,23 @@ export async function main(argv) {
|
|
|
4638
4881
|
if (effectiveCap < cap) {
|
|
4639
4882
|
process.stderr.write(`[h2a] conduct: AIMD breaker — admitting up to ${effectiveCap}/${cap} (rate-limit pressure)\n`);
|
|
4640
4883
|
}
|
|
4884
|
+
// (a'') SESSION LEASES — beat what is working, and REPORT what has stopped.
|
|
4885
|
+
// A finished-but-alive session is invisible to (a): it is `isJobLive`, so
|
|
4886
|
+
// reconcile and the stale sweep both skip it, and it keeps its slot for as
|
|
4887
|
+
// long as it stays alive. The lease pass is the only thing here that reads
|
|
4888
|
+
// inactivity rather than death. It proposes; it never frees a slot itself.
|
|
4889
|
+
try {
|
|
4890
|
+
const supervision = superviseSessionLeases(refreshed);
|
|
4891
|
+
for (const proposal of supervision.proposals) {
|
|
4892
|
+
process.stderr.write(`[h2a] conduct: ${proposal.sessionId} has held its slot without working for ` +
|
|
4893
|
+
`${formatIdle(proposal.idleMs)} (holder ${proposal.holder}, TTL ${formatIdle(proposal.ttlMs)}). ` +
|
|
4894
|
+
`NOT killed — inspect it with: h2a jobs attach ${proposal.sessionId} ` +
|
|
4895
|
+
`free the slot with: h2a jobs reclaim ${proposal.sessionId}\n`);
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
catch {
|
|
4899
|
+
// the lease store is advisory: a failure here must not stop the conductor
|
|
4900
|
+
}
|
|
4641
4901
|
// (b) start pending jobs under the EFFECTIVE cap (oldest-first FIFO).
|
|
4642
4902
|
let started = 0;
|
|
4643
4903
|
for (const id of planNextStarts(refreshed, effectiveCap)) {
|
|
@@ -5629,6 +5889,7 @@ export async function main(argv) {
|
|
|
5629
5889
|
.option("--exec", "force exec-into-Pod-tmux attach (this is the DEFAULT when a tunnel is configured: UTF-8, native scrollback + copy)")
|
|
5630
5890
|
.option("--ws", "force the legacy WS/SSE attach (control-plane proxy) instead of exec")
|
|
5631
5891
|
.option("--local", "force local tmux lookup")
|
|
5892
|
+
.option("--headless-terminal", "attach a persistent 160x48 PTY client without taking over this terminal (local sessions only)")
|
|
5632
5893
|
.action(async (first, second, opts) => {
|
|
5633
5894
|
// Local tmux session? (unless an explicit URL/sessionId pair is given).
|
|
5634
5895
|
if (second === undefined && !looksLikeUrl(first)) {
|
|
@@ -5653,6 +5914,16 @@ export async function main(argv) {
|
|
|
5653
5914
|
process.exitCode = 1;
|
|
5654
5915
|
return;
|
|
5655
5916
|
}
|
|
5917
|
+
if (opts.headlessTerminal) {
|
|
5918
|
+
const terminal = ensureHeadlessTerminal(localName);
|
|
5919
|
+
if (terminal.state === "unavailable") {
|
|
5920
|
+
process.stderr.write(`[h2a] could not attach a persistent terminal to ${first}: ${terminal.reason}\n`);
|
|
5921
|
+
process.exitCode = 1;
|
|
5922
|
+
return;
|
|
5923
|
+
}
|
|
5924
|
+
process.stderr.write(`[h2a] persistent terminal ${terminal.state} for ${first} (${terminal.attachedClients} attached client(s))\n`);
|
|
5925
|
+
return;
|
|
5926
|
+
}
|
|
5656
5927
|
process.exitCode = attachLocalSession(localName);
|
|
5657
5928
|
return;
|
|
5658
5929
|
}
|