Follow-up to #2785. Nine engine files to **zero**, all one question — *"is this task finished?"* — asked in nine places, wrong in every one on a renamed board. ## Census, per file (measured, `--strict` verified) | file | main | here | | --- | ---: | ---: | | `agent-reflection.ts` | 2 | **0** | | `merger-scope-auto-widen.ts` | 2 | **0** | | `worktree-pool.ts` | 2 | **0** | | `cli-agent/state-machine.ts` | 2 | **0** (reclassified — see below) | | `auto-recovery-handlers/branch-worktree.ts` | 1 | **0** | | `cli-agent/task-session.ts` | 1 | **0** (reclassified) | | `merger-integration-worktree.ts` | 1 | **0** | | `merger-orphan-rehome.ts` | 1 | **0** | | `plugin-runner.ts` | 1 | **0** | | **net** | | **−13** | ## The one worth reading: `branch-worktree` had TWO defects, and the census could only see one ```ts if (task.column === "in-progress") { …clear branch… } // counted await this.deps.taskStore.moveTask(task.id, "todo", { … }); // INVISIBLE ``` The census scores **comparisons**. The requeue *destination* is a call argument, so nothing in the backlog ever pointed at it — and it is the worse of the two: a board with no `todo` column was requeued into a lane **that does not exist**. The counted literal is the smaller half (a renamed wip lane meant the stale branch was never cleared, so the card carried a dead branch back into execution). Converting the comparison alone would have dropped a census count and left the board requeuing into nowhere. Destination now resolves through `resolveReboundTarget` (KTD-10 ordering: hold → intake → first column). Reverted **independently**: destination restored → 2 fail (`moveTask` called with `"todo"`, not `"backlog"`); wip test restored → 1 fail (`updateTask` never called). ## The rest - **`plugin-runner`** — `onTaskCompleted` never fired on a renamed board. Every plugin that closes an issue, posts a notification, or records a metric on completion **silently stopped**, with nothing logged. Resolved *asynchronously* inside the existing fire-and-forget seam, not via `resolveTaskWorkflowIrSync` — per `sync-workflow-ir-callsite-allowlist` that reader returns the DEFAULT workflow for every task in production, so a sync guard here would read as converted and still be wrong. The listener is already `void`-dispatched, so awaiting inside it changes no observable ordering (the shape `NotificationService` already uses). - **`merger-orphan-rehome`** — a renamed complete lane made every source task read as unfinished, so orphaned commits were never rehomed and stayed stranded off the integration branch. Resolves by the **trailer id**, not `sourceTask.id`, which the fake store does not populate. - **`agent-reflection`** — `classifyOutcome` returned `null` for every finished task, so both callers treated completed work as nothing to reflect on: one recorded `reflection:skipped` with reason `"not-completed"`, the other silently `continue`d. Reflection captured **nothing at all** on a custom board. - **`worktree-pool`** — shipped tasks' worktrees stayed in the ACTIVE set, so the reclaim pass never returned them and the board walks into worktree exhaustion — a stall whose cause is invisible from the symptom. - **`merger-scope-auto-widen`** — finished cards counted as active claimants, so a merge was blocked by a task that no longer exists in any meaningful sense. - **`merger-integration-worktree`** — a shipped task still counted as a live worktree user, so the integration worktree could never be reused and the merge path took the slower rebuild every time. ## The census OVERSTATED the engine backlog by 3 `cli-agent/state-machine.ts` and `cli-agent/task-session.ts` compare against `done` — but that is a **`CliMachineState`** (`ready`/`busy`/`waitingOnInput`/`done`/`resuming`/`idle`) tracking one CLI agent process. It never reads a board column. The census matches the bare string. Marked `DELIBERATE-LITERAL` rather than left for a later sweep to "convert" a process state into a workflow role. Worth flagging fleet-wide: the backlog total includes at least these three non-columns. ## Revert results (measured, each run) | conversion | reverted → | | --- | --- | | `plugin-runner` complete gate | RENAMED case fails — `onTaskCompleted` never invoked | | `merger-orphan-rehome` source gate | RENAMED case fails — `orphan:false, reason:"source-task-not-done"` | | `branch-worktree` destination | 2 fail — `moveTask` called with `"todo"` | | `branch-worktree` wip test | 1 fail — `updateTask` never called | Each has a **non-vacuous companion** (renamed board, non-complete lane / mid-flight source / non-wip column) so a guard that fired unconditionally would not pass. **Four are NOT revert-proven, and I am not claiming otherwise:** `agent-reflection`, `merger-scope-auto-widen`, `merger-integration-worktree`, `worktree-pool`. Their suites omit a workflow and therefore assert the legacy fallback — they pass before and after. `merger-scope-auto-widen` has no test file at all; `scanIdleWorktrees` is mocked in every suite that touches it and driving it for real needs git worktrees on disk. All four strictly **widen** the finished set (resolved roles ∪ the legacy ids), so default boards are byte-identical. That is the argument for shipping them, not a substitute for coverage. ## Examined and deliberately NOT converted - **`backlog-pressure-reporter:173`** — fed by `listTasks({ column: "todo" })`, a hardcoded **query** filter. On a renamed board `todoFull` is empty and the predicate never runs. Converting it drops a census count and changes nothing observable; the fix belongs at the query layer. - **`auto-merge-finalization:28`** — the catch-arm legacy fallback, which must stay for the same reason `columnRoles.ts` keeps its id fallback. - **`auto-merge-finalization:84`** — only selects between two diagnostic reason strings that are **both** `ok: false`, on a pure validator with no store in scope. Converting it would thread a store through a pure function to change a label. ## Merge resolution note Merging main brought conflicts in `agent-assignment.ts` and `ephemeral-worker-manager.ts`. **Main's versions won both** and mine are dropped: main threads an optional `activeColumns` from `scheduler.ts:2340` (a cleaner seam than widening the store type to resolve internally), and its `isAgentIdle` carries a greptile P1 fix mine lacked — `columnsWithFlag` membership rather than first-per-role, so a workflow declaring two wip lanes has both recognised. That is the fourth time in this program main's version of a contested file was the better one. ## Verification - `pnpm test:gate` — 161 + 487 + 13 + 71, green - `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean - `pnpm lint` — clean - `--strict` exits 0 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Workflow-dependent task completion now recognizes custom lifecycle columns, including renamed boards. * Recovery requeues tasks to the configured destination and clears branch details only from the appropriate work-in-progress column. * Improved handling of completed tasks, orphaned work, shared worktrees, and scope evaluation across custom workflows. * Plugin completion hooks now trigger for any column configured as complete. * **Tests** * Added coverage for renamed workflow columns and custom completion, recovery, and rehoming behavior. * **Documentation** * Clarified CLI state terminology in internal developer comments. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
596 lines
25 KiB
TypeScript
596 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 {
|
|
/* DELIBERATE-LITERAL — `CliMachineState` from the CLI agent state machine, not a lifecycle
|
|
column. See `state-machine.ts#followUp`. */
|
|
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 });
|
|
}
|