594 lines
25 KiB
TypeScript
594 lines
25 KiB
TypeScript
/**
|
|
* Task ↔ CLI session orchestration (CLI Agent Executor, U7).
|
|
*
|
|
* This module is the bridge between the task execute pipeline and the engine-
|
|
* owned CLI session machinery (U2 CliSessionManager + U3 TelemetryHub /
|
|
* state machine + U17 hook scripts). It takes a task + the resolved workflow-
|
|
* node executor config and runs one CLI agent through the execute step:
|
|
*
|
|
* 1. Spawn a CLI session in the task worktree (CliSessionManager.spawn). A
|
|
* CliConcurrencyLimitError at the ceiling is propagated as a typed error
|
|
* (the seam surfaces it as a queued/rejected task state — never a stall).
|
|
* 2. Mint the per-session hook token (TelemetryHub.issueToken) and write the
|
|
* session-scoped hook scripts (writeSessionHookScripts) into a scratch dir.
|
|
* 3. Build the adapter launch settings (the Claude adapter consumes the written
|
|
* hook-script paths via its settings flow). NOTE: the launch invocation is
|
|
* computed by the manager from the settings we pass through `spawn`; this
|
|
* module assembles those settings BEFORE spawn so the hooks are wired at
|
|
* launch.
|
|
* 4. Inject the task prompt after readiness (manager.waitForReady → inject).
|
|
* 5. Subscribe to the state machine and resolve on a terminal signal (R20):
|
|
* - native `done` → success (PTY reaped at handoff)
|
|
* - generic-tier `idle` → NEVER resolves; `confirmAdvance()` resolves
|
|
* it as success (the operator affordance)
|
|
* - `needsAttention` → needs-attention (stall / userExited /
|
|
* authFailed escalation)
|
|
* - `killed` → killed (hard cancel / column exit)
|
|
*
|
|
* Config snapshot: the resolved executor config is captured at launch. A mid-run
|
|
* node-config edit therefore applies to the NEXT run only — this object holds the
|
|
* launch-time snapshot.
|
|
*
|
|
* Re-entry policy (caller-driven):
|
|
* - A needs-replan / RETHINK re-entry launches a FRESH session: the caller
|
|
* kills any prior live session for the task first (see
|
|
* `killLiveTaskSessions`) and calls `launchCliTaskSession` again.
|
|
* - A follow-up to a done task resumes the recorded native session id when the
|
|
* adapter supports it (`followUp`); if resume is unsupported or fails the
|
|
* follow-up falls back to a fresh launch.
|
|
*
|
|
* Pure engine code: no dashboard imports, no HTTP. The hub is the in-process
|
|
* telemetry sink; the dashboard route (U17) forwards validated hook POSTs into it.
|
|
*/
|
|
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type {
|
|
CliAutonomyPosture,
|
|
CliSession,
|
|
CliTerminationReason,
|
|
} from "@fusion/core";
|
|
import type { CliSessionManager } from "./session-manager.js";
|
|
import type { TelemetryHub } from "./telemetry-hub.js";
|
|
import type { CliAdapterRegistry } from "./adapter.js";
|
|
import type { CliMachineState } from "./state-machine.js";
|
|
import {
|
|
HOOK_SCRIPT_NAMES,
|
|
writeSessionHookScripts,
|
|
cleanupSessionHookDir,
|
|
} from "./hook-scripts.js";
|
|
import {
|
|
assertAutonomyApproved,
|
|
type AutonomyApprovalLookup,
|
|
type CliAgentResolveSettings,
|
|
type EffectivePosture,
|
|
} from "./autonomy.js";
|
|
|
|
// ── Outcome ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Terminal outcome of a CLI task session, mapped from the authoritative state
|
|
* machine onto the pipeline contract.
|
|
*
|
|
* - `success` — a positive completion signal arrived (native done, or the
|
|
* generic-tier confirm-advance affordance). The pipeline
|
|
* advances; the PTY is reaped at the execute→in-review handoff.
|
|
* - `needs-attention`— stall backstop, clean userExit mid-task, or auth failure.
|
|
* The task does NOT advance and is NOT a hard failure — it
|
|
* needs a human.
|
|
* - `killed` — hard cancel / column exit SIGKILL'd the PTY. Never resume-
|
|
* eligible; the task left in-progress.
|
|
* - `user-exited` — the child exited cleanly mid-task (no done). Surfaced as
|
|
* needs-attention by the caller, but recorded precisely.
|
|
* - `auth-failed` — credential failure; needs re-authentication.
|
|
*/
|
|
export type CliTaskOutcomeKind =
|
|
| "success"
|
|
| "needs-attention"
|
|
| "killed"
|
|
| "user-exited"
|
|
| "auth-failed";
|
|
|
|
export interface CliTaskOutcome {
|
|
kind: CliTaskOutcomeKind;
|
|
/** The CLI session id this outcome belongs to. */
|
|
sessionId: string;
|
|
/** Termination reason recorded on the session record, when the session ended. */
|
|
terminationReason: CliTerminationReason | null;
|
|
}
|
|
|
|
// ── Resolved executor config (snapshotted at launch) ───────────────────────────
|
|
|
|
/**
|
|
* The cli-agent executor config resolved for ONE launch. A snapshot: the caller
|
|
* resolves node config (+ any per-task override) before launch and hands it here;
|
|
* a later edit to the node config does not affect this live session.
|
|
*/
|
|
export interface ResolvedCliExecutorConfig {
|
|
/** Adapter id to drive the session (resolved against the registry). */
|
|
cliAdapterId: string;
|
|
/** Autonomy posture (drives privileged flags + resume caps). */
|
|
cliAutonomy?: CliAutonomyPosture | null;
|
|
/** Notification settings forwarded to waiting-on-input dispatch (opaque here). */
|
|
cliNotify?: Record<string, unknown> | null;
|
|
/** Adapter launch settings (model, command override, extra args, …). */
|
|
settings?: Record<string, unknown>;
|
|
/**
|
|
* Per-adapter operator launch config (U15), resolved from
|
|
* `GlobalSettings.cliAgents[adapterId]`. Drives elevation detection + the
|
|
* approval gate, and is folded into the adapter launch settings at spawn.
|
|
*/
|
|
cliAgentSettings?: CliAgentResolveSettings | null;
|
|
}
|
|
|
|
// ── Launch options ─────────────────────────────────────────────────────────────
|
|
|
|
export interface LaunchCliTaskSessionOptions {
|
|
/** Owning task id. */
|
|
taskId: string;
|
|
/** Project the task/session belongs to. */
|
|
projectId: string;
|
|
/** The task worktree (PTY cwd). Required — a write-capable CLI must not run at root. */
|
|
worktreePath: string;
|
|
/** The prompt to inject after readiness. */
|
|
prompt: string;
|
|
/** Resolved (snapshotted) executor config. */
|
|
config: ResolvedCliExecutorConfig;
|
|
/** Engine-owned PTY session manager (U2). */
|
|
manager: CliSessionManager;
|
|
/** In-process telemetry hub (U3) — mints the hook token + owns the state machine. */
|
|
hub: TelemetryHub;
|
|
/** Adapter registry (U2) — to read capabilities for the wiring decisions. */
|
|
registry: CliAdapterRegistry;
|
|
/**
|
|
* Absolute URL of the dashboard hook ingestion endpoint the hook scripts POST
|
|
* to (e.g. `http://127.0.0.1:4040/api/cli-agent/hooks`). The engine has no HTTP
|
|
* server; the dashboard serves this route (U17).
|
|
*/
|
|
hookEndpointUrl: string;
|
|
/**
|
|
* Test/override seam for the hook scratch dir root. Defaults to the OS temp
|
|
* dir; production callers may scope it under the engine's runtime dir.
|
|
*/
|
|
hookDirRoot?: string;
|
|
/**
|
|
* Per-project autonomy-approval lookup (U15). Backs the elevation approval
|
|
* gate: when the resolved effective posture is elevated and this returns false,
|
|
* launch fails with a typed `CliAutonomyNotApprovedError` (never a stall).
|
|
* When omitted, an elevated posture fails closed (treated as unapproved).
|
|
*/
|
|
isAutonomyApproved?: AutonomyApprovalLookup;
|
|
/**
|
|
* Optional logger for lifecycle breadcrumbs. Best-effort; never throws.
|
|
*/
|
|
log?: (msg: string) => void;
|
|
}
|
|
|
|
// ── CliTaskSession ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A live task-bound CLI session. Holds the launch-time config snapshot, resolves
|
|
* `result()` on a terminal state-machine signal, and exposes `confirmAdvance()`
|
|
* (generic tier), `followUp()` (done-task resume), and `reap()`/`kill()`.
|
|
*/
|
|
export class CliTaskSession {
|
|
readonly taskId: string;
|
|
readonly sessionId: string;
|
|
readonly config: ResolvedCliExecutorConfig;
|
|
|
|
private readonly manager: CliSessionManager;
|
|
private readonly hub: TelemetryHub;
|
|
private readonly registry: CliAdapterRegistry;
|
|
private readonly hookDir: string;
|
|
private readonly hookEndpointUrl: string;
|
|
private readonly log: (msg: string) => void;
|
|
|
|
private settled = false;
|
|
private resolveResult!: (outcome: CliTaskOutcome) => void;
|
|
private resultPromise: Promise<CliTaskOutcome>;
|
|
private unsubscribe: (() => void) | null = null;
|
|
|
|
private constructor(args: {
|
|
taskId: string;
|
|
sessionId: string;
|
|
config: ResolvedCliExecutorConfig;
|
|
manager: CliSessionManager;
|
|
hub: TelemetryHub;
|
|
registry: CliAdapterRegistry;
|
|
hookDir: string;
|
|
hookEndpointUrl: string;
|
|
log: (msg: string) => void;
|
|
}) {
|
|
this.taskId = args.taskId;
|
|
this.sessionId = args.sessionId;
|
|
this.config = args.config;
|
|
this.manager = args.manager;
|
|
this.hub = args.hub;
|
|
this.registry = args.registry;
|
|
this.hookDir = args.hookDir;
|
|
this.hookEndpointUrl = args.hookEndpointUrl;
|
|
this.log = args.log;
|
|
this.resultPromise = new Promise<CliTaskOutcome>((resolve) => {
|
|
this.resolveResult = resolve;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Launch a fresh CLI session for the task. Spawns in the worktree, writes the
|
|
* hook scripts + issues the hub token, injects the prompt after readiness, and
|
|
* subscribes to the state machine. Throws `CliConcurrencyLimitError` at the
|
|
* pool ceiling (the caller surfaces it as a queued/rejected task state).
|
|
*/
|
|
static async launch(opts: LaunchCliTaskSessionOptions): Promise<CliTaskSession> {
|
|
const log = opts.log ?? (() => {});
|
|
const adapter = opts.registry.get(opts.config.cliAdapterId);
|
|
|
|
// 0. Autonomy approval gate (U15). Resolve the EFFECTIVE posture from the
|
|
// fully resolved argv + env (NOT the autonomy field alone) and enforce the
|
|
// per-project approval for any elevation. A missing lookup fails closed.
|
|
// Runs BEFORE any side effects (scratch dir / spawn) so an unapproved
|
|
// elevation never reserves a concurrency slot or leaves a scratch dir.
|
|
const effectivePosture: EffectivePosture = await assertAutonomyApproved({
|
|
adapter,
|
|
settings: opts.config.cliAgentSettings ?? null,
|
|
nodeConfig: { cliAutonomy: opts.config.cliAutonomy ?? null },
|
|
projectId: opts.projectId,
|
|
isApproved: opts.isAutonomyApproved ?? (() => false),
|
|
});
|
|
|
|
// 1. Scratch dir for the session-scoped hook scripts + settings.
|
|
const root = opts.hookDirRoot ?? tmpdir();
|
|
const hookDir = await mkdtemp(join(root, "fusion-cli-hooks-"));
|
|
|
|
// We cannot write the hook scripts until we have a session id (the scripts
|
|
// embed it), and we cannot get a session id without spawning. So: spawn FIRST
|
|
// with a settings shape that points at the (deterministic) script paths, then
|
|
// write the scripts before readiness (the agent only invokes a hook once it is
|
|
// up — readiness gates the first prompt injection, and the SessionStart hook
|
|
// fires around the same time). To avoid a race we write the scripts as part of
|
|
// launch, immediately after spawn, before injecting.
|
|
const hookScriptPath = join(hookDir, HOOK_SCRIPT_NAMES.hook);
|
|
const settingsPath = join(hookDir, "settings.json");
|
|
|
|
// Fold the per-adapter operator settings (U15) into the launch settings bag
|
|
// so they actually reach the child: command override → `command`, extra args
|
|
// → `extraArgs`, env additions → `envAllowlist`. Service credentials are
|
|
// ALWAYS excluded from the env allowlist regardless of what the operator
|
|
// added (a user must never widen the allowlist to leak FUSION_* creds).
|
|
const cliAgentSettings = opts.config.cliAgentSettings ?? null;
|
|
const operatorEnvAdditions = (cliAgentSettings?.envAdditions ?? []).filter(
|
|
(k) => !/^FUSION_/i.test(k),
|
|
);
|
|
const priorAllowlist = Array.isArray(
|
|
(opts.config.settings as Record<string, unknown> | undefined)?.envAllowlist,
|
|
)
|
|
? ((opts.config.settings as Record<string, unknown>).envAllowlist as string[])
|
|
: [];
|
|
|
|
// Build adapter launch settings carrying the hook-script refs. Claude's
|
|
// settings flow reads `hookScripts` + `settingsPath` off ctx.settings; other
|
|
// adapters ignore unknown keys.
|
|
const settings: Record<string, unknown> = {
|
|
...(opts.config.settings ?? {}),
|
|
...(cliAgentSettings?.commandOverride
|
|
? { command: cliAgentSettings.commandOverride }
|
|
: {}),
|
|
...(cliAgentSettings?.extraArgs && cliAgentSettings.extraArgs.length > 0
|
|
? { extraArgs: [...cliAgentSettings.extraArgs] }
|
|
: {}),
|
|
envAllowlist: [...new Set([...priorAllowlist, ...operatorEnvAdditions])],
|
|
hookScripts: {
|
|
stopScript: hookScriptPath,
|
|
notificationScript: hookScriptPath,
|
|
permissionScript: hookScriptPath,
|
|
sessionStartScript: hookScriptPath,
|
|
toolActivityScript: hookScriptPath,
|
|
},
|
|
settingsPath,
|
|
};
|
|
|
|
// 2. Spawn (reserves the concurrency slot; throws CliConcurrencyLimitError at
|
|
// the ceiling). The record is created with state "starting".
|
|
let record: CliSession;
|
|
try {
|
|
record = await opts.manager.spawn({
|
|
adapterId: opts.config.cliAdapterId,
|
|
projectId: opts.projectId,
|
|
purpose: "execute",
|
|
taskId: opts.taskId,
|
|
worktreePath: opts.worktreePath,
|
|
// Denormalize the EFFECTIVE posture (derived from resolved argv+env) onto
|
|
// the session record so the posture chip reflects the real launch
|
|
// posture, not the autonomy field alone. The autonomy intent fields are
|
|
// preserved (buildLaunch still reads `autoApprove`).
|
|
posture: {
|
|
...(opts.config.cliAutonomy ?? {}),
|
|
// `autonomyMode: "elevated"` is an alternate channel to the field; map
|
|
// it onto `autoApprove` so the adapter's buildLaunch emits the
|
|
// privileged flags (it keys off `posture.autoApprove`).
|
|
...(cliAgentSettings?.autonomyMode === "elevated"
|
|
? { autoApprove: true }
|
|
: {}),
|
|
effectivePosture: {
|
|
mode: effectivePosture.mode,
|
|
elevated: effectivePosture.elevated,
|
|
flags: effectivePosture.flags,
|
|
},
|
|
},
|
|
settings,
|
|
});
|
|
} catch (err) {
|
|
// Clean up the scratch dir we created before re-throwing (ceiling / spawn).
|
|
await cleanupSessionHookDir(hookDir).catch(() => {});
|
|
throw err;
|
|
}
|
|
|
|
// 3. Mint the per-session hook token + write the hook scripts.
|
|
const token = opts.hub.issueToken(record.id);
|
|
await writeSessionHookScripts({
|
|
sessionId: record.id,
|
|
token,
|
|
endpointUrl: opts.hookEndpointUrl,
|
|
dir: hookDir,
|
|
});
|
|
|
|
const session = new CliTaskSession({
|
|
taskId: opts.taskId,
|
|
sessionId: record.id,
|
|
config: opts.config,
|
|
manager: opts.manager,
|
|
hub: opts.hub,
|
|
registry: opts.registry,
|
|
hookDir,
|
|
hookEndpointUrl: opts.hookEndpointUrl,
|
|
log,
|
|
});
|
|
|
|
// 4. Subscribe to the authoritative state machine BEFORE injecting so a fast
|
|
// done is never missed.
|
|
session.subscribe();
|
|
|
|
// 5. Inject the prompt after readiness (fire-and-forget; readiness gates it).
|
|
void session.injectAfterReady(opts.prompt, adapter.capabilities.nativeDone);
|
|
|
|
log(`cli-task-session ${record.id}: launched for task ${opts.taskId} (adapter ${opts.config.cliAdapterId})`);
|
|
return session;
|
|
}
|
|
|
|
/** The terminal outcome promise (R20 gating). */
|
|
result(): Promise<CliTaskOutcome> {
|
|
return this.resultPromise;
|
|
}
|
|
|
|
/** Whether the session has reached a terminal outcome. */
|
|
get isSettled(): boolean {
|
|
return this.settled;
|
|
}
|
|
|
|
/**
|
|
* Operator confirm-advance for the generic heuristic tier (origin R20). The
|
|
* generic tier NEVER auto-advances on idle — this is the ONLY positive
|
|
* completion path for an adapter without a native done signal. Resolves the
|
|
* result as success (so the caller advances the pipeline). A no-op once settled.
|
|
*/
|
|
confirmAdvance(): void {
|
|
if (this.settled) return;
|
|
this.log(`cli-task-session ${this.sessionId}: confirm-advance (generic tier)`);
|
|
this.finish({ kind: "success", sessionId: this.sessionId, terminationReason: "completed" });
|
|
}
|
|
|
|
/**
|
|
* Follow up a DONE task by resuming the recorded native session id (when the
|
|
* adapter supports resume). Returns true when a resume was driven; false when
|
|
* resume is unsupported / no native id / the session is not live — the caller
|
|
* should then launch a fresh session.
|
|
*
|
|
* This injects the follow-up prompt; it does NOT relaunch the PTY. When the PTY
|
|
* has been reaped, resume is the caller's job (relaunch via the manager with the
|
|
* adapter's buildResume); here we only handle the still-live case + report
|
|
* resume capability.
|
|
*/
|
|
async followUp(prompt: string): Promise<boolean> {
|
|
const adapter = this.registry.get(this.config.cliAdapterId);
|
|
if (!adapter.capabilities.supportsResume) return false;
|
|
if (!this.manager.isLive(this.sessionId)) return false;
|
|
// Live + resumable: a follow-up is just another injection on the live PTY.
|
|
// Re-arm the result promise so the next done resolves it again.
|
|
if (this.settled) this.rearm();
|
|
this.subscribe();
|
|
// Drive the authoritative state machine done→busy BEFORE injecting. If we
|
|
// leave it parked in `done`, the next native done is idempotent (no state
|
|
// change emitted), so onMachineState("done") never fires and the re-armed
|
|
// result promise never resolves. The hub swallows signalBusy-from-done, so
|
|
// we transition via the machine's followUp()/injectPrompt directly.
|
|
const machine = this.hub.getStateMachine(this.sessionId);
|
|
if (machine) {
|
|
try {
|
|
if (machine.getState() === "done") machine.followUp();
|
|
else if (machine.getState() === "ready" || machine.getState() === "resuming") {
|
|
machine.injectPrompt();
|
|
}
|
|
} catch {
|
|
// best-effort transition
|
|
}
|
|
}
|
|
await this.manager.inject(this.sessionId, prompt);
|
|
this.log(`cli-task-session ${this.sessionId}: follow-up injected (live resume)`);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Reap the PTY at the execute→in-review handoff. Graceful first (the manager's
|
|
* kill is a scoped SIGKILL — node-pty has no graceful-then-kill ladder, so this
|
|
* is the single reap), recording `completed` (the task advanced on a positive
|
|
* done). Cleans up the hook dir + invalidates the token.
|
|
*/
|
|
async reap(): Promise<void> {
|
|
this.manager.kill(this.sessionId, "completed");
|
|
await this.teardown();
|
|
this.log(`cli-task-session ${this.sessionId}: reaped at handoff`);
|
|
}
|
|
|
|
/**
|
|
* Hard-cancel kill: SIGKILL the PTY and mark `killed` (never resume-eligible).
|
|
* Used by the abort/hard-cancel path. Idempotent. Resolves a pending result as
|
|
* `killed`.
|
|
*/
|
|
async kill(reason: CliTerminationReason = "killed"): Promise<void> {
|
|
this.manager.kill(this.sessionId, reason);
|
|
await this.teardown();
|
|
if (!this.settled) {
|
|
this.finish({ kind: "killed", sessionId: this.sessionId, terminationReason: reason });
|
|
}
|
|
this.log(`cli-task-session ${this.sessionId}: killed (${reason})`);
|
|
}
|
|
|
|
// ── Internals ────────────────────────────────────────────────────────────
|
|
|
|
private subscribe(): void {
|
|
// Re-subscribe is safe: drop the prior subscription first.
|
|
this.unsubscribe?.();
|
|
const machine = this.hub.getStateMachine(this.sessionId);
|
|
if (!machine) {
|
|
// No machine (hub never registered the session, e.g. it died pre-token).
|
|
// Fall back to a settled-on-not-live check on the next tick.
|
|
return;
|
|
}
|
|
this.unsubscribe = machine.onStateChange((change) => {
|
|
this.onMachineState(change.state, change.terminationReason);
|
|
});
|
|
}
|
|
|
|
private onMachineState(state: CliMachineState, reason: CliTerminationReason | null): void {
|
|
if (this.settled) return;
|
|
switch (state) {
|
|
case "done":
|
|
this.finish({ kind: "success", sessionId: this.sessionId, terminationReason: "completed" });
|
|
break;
|
|
case "needsAttention":
|
|
this.finish({
|
|
kind: reason === "authFailed" ? "auth-failed" : reason === "userExited" ? "user-exited" : "needs-attention",
|
|
sessionId: this.sessionId,
|
|
terminationReason: reason,
|
|
});
|
|
break;
|
|
case "dead":
|
|
// A dead landing carrying a terminal reason that doesn't escalate on its
|
|
// own (killed). authFailed/userExited transition on through
|
|
// escalateToNeedsAttention; killed is terminal here.
|
|
if (reason === "killed") {
|
|
this.finish({ kind: "killed", sessionId: this.sessionId, terminationReason: reason });
|
|
} else if (reason === "authFailed") {
|
|
this.finish({ kind: "auth-failed", sessionId: this.sessionId, terminationReason: reason });
|
|
} else if (reason === "userExited") {
|
|
this.finish({ kind: "user-exited", sessionId: this.sessionId, terminationReason: reason });
|
|
}
|
|
// crashed/engineDeath land as `resuming` (not `dead`) — left for U8.
|
|
break;
|
|
// `idle` (generic tier) NEVER resolves — confirmAdvance() is the only path.
|
|
// busy / ready / waitingOnInput / resuming are non-terminal: keep waiting.
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async injectAfterReady(prompt: string, _nativeDone: boolean): Promise<void> {
|
|
try {
|
|
await this.manager.waitForReady(this.sessionId);
|
|
} catch {
|
|
// Session may have died before readiness — the state machine / exit handler
|
|
// resolves the result; nothing to inject.
|
|
return;
|
|
}
|
|
if (this.settled) return;
|
|
try {
|
|
await this.manager.inject(this.sessionId, prompt);
|
|
// HTD: "ready → busy: prompt injected". The task-session is the component
|
|
// that injects, so it drives the ready→busy transition on the authoritative
|
|
// machine. The manager's PTY-output readiness is the fallback readiness
|
|
// signal (per the adapter contract); when the native SessionStart hook has
|
|
// not yet landed the machine may still be `starting`, so mark it ready
|
|
// first. Native adapters that also emit `busy` telemetry are idempotent
|
|
// here (signalBusy from busy re-arms the watchdog).
|
|
const machine = this.hub.getStateMachine(this.sessionId);
|
|
if (machine) {
|
|
try {
|
|
if (machine.getState() === "starting") machine.markReady();
|
|
if (machine.getState() === "ready") machine.injectPrompt();
|
|
} catch {
|
|
// best-effort transition
|
|
}
|
|
}
|
|
this.log(`cli-task-session ${this.sessionId}: prompt injected after readiness`);
|
|
} catch {
|
|
// Inject can fail if the session died mid-readiness — the terminal handler
|
|
// resolves the outcome.
|
|
}
|
|
}
|
|
|
|
private finish(outcome: CliTaskOutcome): void {
|
|
if (this.settled) return;
|
|
this.settled = true;
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
this.resolveResult(outcome);
|
|
}
|
|
|
|
/** Re-open the result promise for a follow-up turn. */
|
|
private rearm(): void {
|
|
this.settled = false;
|
|
// A new promise so a fresh `result()` call awaits the next turn.
|
|
this.resultPromise = new Promise<CliTaskOutcome>((resolve) => {
|
|
this.resolveResult = resolve;
|
|
});
|
|
}
|
|
|
|
private async teardown(): Promise<void> {
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
try {
|
|
this.hub.flush(this.sessionId);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
this.hub.invalidate(this.sessionId);
|
|
await cleanupSessionHookDir(this.hookDir).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
/** Launch a fresh CLI task session (thin wrapper over the static factory). */
|
|
export function launchCliTaskSession(
|
|
opts: LaunchCliTaskSessionOptions,
|
|
): Promise<CliTaskSession> {
|
|
return CliTaskSession.launch(opts);
|
|
}
|
|
|
|
/**
|
|
* Kill any prior LIVE CLI session(s) for a task before a fresh re-entry launch
|
|
* (needs-replan / RETHINK context reset). Marks them `killed` (never resume-
|
|
* eligible). Best-effort: a dead/missing session is a no-op. Returns the count
|
|
* of sessions killed.
|
|
*/
|
|
export function killLiveTaskSessions(
|
|
taskId: string,
|
|
manager: CliSessionManager,
|
|
store: { listByTask(taskId: string): CliSession[] },
|
|
): number {
|
|
let killed = 0;
|
|
for (const record of store.listByTask(taskId)) {
|
|
if (manager.isLive(record.id)) {
|
|
manager.kill(record.id, "killed");
|
|
killed += 1;
|
|
}
|
|
}
|
|
return killed;
|
|
}
|
|
|
|
/** Clean up a hook scratch dir (re-exported for callers managing dirs directly). */
|
|
export async function cleanupCliHookDir(dir: string): Promise<void> {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|