@cruxy/cli 0.24.0 → 0.26.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/agent/session.d.ts +13 -0
- package/dist/agent/session.js +6 -0
- package/dist/approval/prompt.d.ts +7 -1
- package/dist/approval/prompt.js +52 -17
- package/dist/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +12 -0
- package/dist/cli/commands/skills.js +10 -2
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +113 -1
- package/dist/cli/session-factory.d.ts +4 -12
- package/dist/cli/session-factory.js +50 -96
- package/dist/components/frame.d.ts +6 -3
- package/dist/components/frame.js +21 -23
- package/dist/components/fuzzy.js +5 -1
- package/dist/components/select.js +4 -1
- package/dist/config/schema.d.ts +86 -0
- package/dist/config/schema.js +41 -0
- package/dist/errors/constructors.d.ts +18 -0
- package/dist/errors/constructors.js +49 -0
- package/dist/errors/types.d.ts +13 -0
- package/dist/errors/types.js +21 -0
- package/dist/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- package/dist/render/capabilities.d.ts +11 -0
- package/dist/render/capabilities.js +19 -3
- package/dist/render/diff.d.ts +1 -1
- package/dist/render/diff.js +23 -7
- package/dist/render/index.d.ts +4 -2
- package/dist/render/index.js +8 -2
- package/dist/render/layout.d.ts +59 -0
- package/dist/render/layout.js +158 -0
- package/dist/render/resize.d.ts +36 -0
- package/dist/render/resize.js +45 -0
- package/dist/render/state.d.ts +13 -0
- package/dist/render/state.js +38 -0
- package/dist/render/tty-renderer.d.ts +8 -0
- package/dist/render/tty-renderer.js +36 -11
- package/dist/render/types.d.ts +15 -1
- package/dist/subagent/orchestrator.d.ts +9 -0
- package/dist/subagent/orchestrator.js +6 -1
- package/dist/subagent/semaphore.d.ts +40 -11
- package/dist/subagent/semaphore.js +23 -26
- package/package.json +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { themeForColor } from "../theme/index.js";
|
|
2
|
+
/** Non-terminal capabilities: a background job renders to NOTHING on screen. */
|
|
3
|
+
const OFFSCREEN_CAPS = {
|
|
4
|
+
tty: false,
|
|
5
|
+
color: false,
|
|
6
|
+
cursor: false,
|
|
7
|
+
spinner: false,
|
|
8
|
+
reducedMotion: true,
|
|
9
|
+
screenReader: false,
|
|
10
|
+
unicode: true,
|
|
11
|
+
width: 80,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A {@link StreamRenderer} for a background job (C.28) that captures activity into
|
|
15
|
+
* the job's log buffer and writes NOTHING to any terminal — a job runs
|
|
16
|
+
* non-interactively, off screen, and its foreground session owns the terminal.
|
|
17
|
+
* `cruxy logs <id>` (a REPL `/logs`) reads back what was captured here.
|
|
18
|
+
*
|
|
19
|
+
* Assistant text is accumulated and flushed a line at a time on `endSegment`;
|
|
20
|
+
* committed chrome notes and tool-call completions are captured verbatim. The
|
|
21
|
+
* live-region methods (status, phase, progress, prompt-resolved) are dropped —
|
|
22
|
+
* transient decor has no meaning in an append-only log.
|
|
23
|
+
*/
|
|
24
|
+
export class JobLogRenderer {
|
|
25
|
+
sink;
|
|
26
|
+
caps = OFFSCREEN_CAPS;
|
|
27
|
+
theme = themeForColor(false);
|
|
28
|
+
pending = "";
|
|
29
|
+
constructor(sink) {
|
|
30
|
+
this.sink = sink;
|
|
31
|
+
}
|
|
32
|
+
beginTurn() {
|
|
33
|
+
this.pending = "";
|
|
34
|
+
}
|
|
35
|
+
write(delta) {
|
|
36
|
+
this.pending += delta;
|
|
37
|
+
// Flush any complete lines immediately; keep the trailing partial line.
|
|
38
|
+
const nl = this.pending.lastIndexOf("\n");
|
|
39
|
+
if (nl === -1)
|
|
40
|
+
return;
|
|
41
|
+
const complete = this.pending.slice(0, nl);
|
|
42
|
+
this.pending = this.pending.slice(nl + 1);
|
|
43
|
+
for (const line of complete.split("\n"))
|
|
44
|
+
this.sink("out", line);
|
|
45
|
+
}
|
|
46
|
+
endSegment() {
|
|
47
|
+
if (this.pending.trim())
|
|
48
|
+
this.sink("out", this.pending.trimEnd());
|
|
49
|
+
this.pending = "";
|
|
50
|
+
}
|
|
51
|
+
note(text) {
|
|
52
|
+
this.sink("out", text);
|
|
53
|
+
}
|
|
54
|
+
toolLifecycle(event) {
|
|
55
|
+
if (event.event !== "end")
|
|
56
|
+
return; // only the committed outcome is log-worthy
|
|
57
|
+
const mark = event.ok ? "ok" : "fail";
|
|
58
|
+
this.sink(event.ok ? "out" : "err", `[${mark}] ${event.label}`);
|
|
59
|
+
}
|
|
60
|
+
endTurn() {
|
|
61
|
+
this.endSegment();
|
|
62
|
+
}
|
|
63
|
+
// Transient decor / previews have no place in an append-only job log.
|
|
64
|
+
preview() { }
|
|
65
|
+
status() { }
|
|
66
|
+
setPhase() { }
|
|
67
|
+
progress() { }
|
|
68
|
+
promptResolved() { }
|
|
69
|
+
close() { }
|
|
70
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Provider } from "@cruxy/sdk";
|
|
2
|
+
import { runAgent } from "../agent/loop.js";
|
|
3
|
+
import { type ApprovalMutex, type PromptIO } from "../approval/index.js";
|
|
4
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
5
|
+
import type { Router } from "../routing/index.js";
|
|
6
|
+
import type { SandboxService } from "../sandbox/index.js";
|
|
7
|
+
import { Semaphore } from "../subagent/index.js";
|
|
8
|
+
import type { ToolRegistry } from "../tools/index.js";
|
|
9
|
+
import type { logger as Logger } from "../utils/logger.js";
|
|
10
|
+
import { Workspace } from "../workspace/index.js";
|
|
11
|
+
import { ApprovalQueue } from "./approval-queue.js";
|
|
12
|
+
import type { JobLog, JobSpec, JobView } from "./types.js";
|
|
13
|
+
/**
|
|
14
|
+
* Everything the manager needs from the surrounding session, injected by the
|
|
15
|
+
* session factory. The SHARED members are the crux of C.28's "one system, not
|
|
16
|
+
* two": `semaphore`, `approvalQueue`, and `approvalMutex` are the SAME instances
|
|
17
|
+
* the foreground loop and subagents use, so a job contends for the one execution
|
|
18
|
+
* cap and serializes its prompts/checkpoints on the one mutex — never a parallel
|
|
19
|
+
* universe with its own limits.
|
|
20
|
+
*/
|
|
21
|
+
export interface JobManagerDeps {
|
|
22
|
+
config: CruxyConfig;
|
|
23
|
+
provider: Provider;
|
|
24
|
+
router?: Router;
|
|
25
|
+
/** The parent's registry — the ceiling every job's tool scope derives from. */
|
|
26
|
+
parentRegistry: ToolRegistry;
|
|
27
|
+
cwd: string;
|
|
28
|
+
workspace: Workspace;
|
|
29
|
+
logger: typeof Logger;
|
|
30
|
+
git?: {
|
|
31
|
+
branch: string;
|
|
32
|
+
dirty: boolean;
|
|
33
|
+
} | null;
|
|
34
|
+
projectInstructions?: string | null;
|
|
35
|
+
sandbox?: SandboxService;
|
|
36
|
+
/** The ONE execution semaphore, shared with subagents (JC-D, the shared cap). */
|
|
37
|
+
semaphore: Semaphore;
|
|
38
|
+
/** The ONE foreground pending-approval queue jobs produce onto. */
|
|
39
|
+
approvalQueue: ApprovalQueue;
|
|
40
|
+
/** The ONE approval mutex — job prompts/checkpoints serialize on it. */
|
|
41
|
+
approvalMutex: ApprovalMutex;
|
|
42
|
+
/**
|
|
43
|
+
* Whether an interactive foreground exists to service the queue. FALSE (a
|
|
44
|
+
* one-shot / piped run) means a job's gated action fails loud with
|
|
45
|
+
* `CRUXY_E_APPROVAL_REQUIRED` — it never auto-approves and never hangs on a
|
|
46
|
+
* human who will never come.
|
|
47
|
+
*/
|
|
48
|
+
foregroundInteractive: boolean;
|
|
49
|
+
/** The shared prompt I/O used when the foreground services a job's request. */
|
|
50
|
+
promptIO: PromptIO;
|
|
51
|
+
/** Test seam: the loop driver. Defaults to the real {@link runAgent}. */
|
|
52
|
+
runAgentFn?: typeof runAgent;
|
|
53
|
+
/** Test seam: deterministic job ids. Defaults to `job-<seq>-<rand>`. */
|
|
54
|
+
idFactory?: () => string;
|
|
55
|
+
/** Test seam: log-line clock (ms). Defaults to `Date.now`. */
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Session-scoped background jobs (C.28). The main agent dispatches a job with
|
|
60
|
+
* `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
|
|
61
|
+
* with the foreground session, off screen (its output goes to a log buffer). A
|
|
62
|
+
* gated action pauses the job, releases its execution slot, and enqueues an
|
|
63
|
+
* approval request the foreground human services between turns; on approval the
|
|
64
|
+
* job re-acquires a slot (with priority) and runs on — its pre- AND post-pause
|
|
65
|
+
* mutations coalescing under ONE checkpoint. Cancel and session exit kill-tree a
|
|
66
|
+
* job's process tree (via its cancellation signal), leaving no orphan.
|
|
67
|
+
*
|
|
68
|
+
* The manager is session-scoped and dies with the session (NOT a daemon): nothing
|
|
69
|
+
* here persists a live registry across processes. What survives on disk is a
|
|
70
|
+
* job's checkpoint set, so `cruxy rollback <id>` still works afterwards.
|
|
71
|
+
*/
|
|
72
|
+
export declare class JobManager {
|
|
73
|
+
private readonly deps;
|
|
74
|
+
private readonly jobs;
|
|
75
|
+
/** In-flight executions, awaited on {@link cancelAll} so teardown completes. */
|
|
76
|
+
private readonly running;
|
|
77
|
+
private readonly idFactory;
|
|
78
|
+
private readonly now;
|
|
79
|
+
private readonly runAgentFn;
|
|
80
|
+
private seq;
|
|
81
|
+
constructor(deps: JobManagerDeps);
|
|
82
|
+
/**
|
|
83
|
+
* Dispatch a background job (the `run_in_background` seam). Fails loud when the
|
|
84
|
+
* feature is disabled, when the live-job ceiling is reached, or when a named
|
|
85
|
+
* root is unknown — all BEFORE the job is registered, so a refused dispatch
|
|
86
|
+
* leaves no ghost. Returns a view immediately; the job runs asynchronously.
|
|
87
|
+
*/
|
|
88
|
+
dispatch(spec: JobSpec): JobView;
|
|
89
|
+
/** Live view of every job this session, dispatch order. */
|
|
90
|
+
list(): JobView[];
|
|
91
|
+
/** One job's view, or undefined if the id is unknown. */
|
|
92
|
+
get(id: string): JobView | undefined;
|
|
93
|
+
/** One job's full log (for `/logs <id>`). Fails loud on an unknown id. */
|
|
94
|
+
logs(id: string): JobLog;
|
|
95
|
+
/**
|
|
96
|
+
* Cancel one job: abort its run (kill-tree'ing any in-flight process via the
|
|
97
|
+
* signal reaching `ctx.signal`) and, if it is paused, withdraw its queued
|
|
98
|
+
* approval so it stops waiting. A checkpoint it already took SURVIVES for
|
|
99
|
+
* review/rollback. Fails loud on an unknown id; a no-op on an already-terminal
|
|
100
|
+
* job (returns false).
|
|
101
|
+
*/
|
|
102
|
+
cancel(id: string): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Cancel EVERY live job (session exit / teardown) and AWAIT their teardown, so
|
|
105
|
+
* nothing is left running or orphaned. Returns how many were cancelled — the
|
|
106
|
+
* caller prints the honest "N job(s) cancelled". Idempotent.
|
|
107
|
+
*/
|
|
108
|
+
cancelAll(reason: string): Promise<number>;
|
|
109
|
+
/** Jobs that are not yet terminal (queued + running + paused). */
|
|
110
|
+
liveCount(): number;
|
|
111
|
+
/** Whether any job is paused awaiting a foreground approval (drive the drain). */
|
|
112
|
+
hasPendingApprovals(): boolean;
|
|
113
|
+
/** Service every pending job approval on the foreground (called between turns). */
|
|
114
|
+
serviceApprovals(): Promise<number>;
|
|
115
|
+
/** Abort a job's run and unblock it if paused (shared by cancel/cancelAll). */
|
|
116
|
+
private abort;
|
|
117
|
+
/** Drive one job to completion, mapping every outcome onto its status. */
|
|
118
|
+
private execute;
|
|
119
|
+
/** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
|
|
120
|
+
private runArgs;
|
|
121
|
+
/**
|
|
122
|
+
* The job's approval gate — the producer side of the pending-approval queue.
|
|
123
|
+
* A read-tier action passes freely. A mutate/destructive action with NO
|
|
124
|
+
* interactive foreground fails loud (never auto-approve, never hang). Otherwise
|
|
125
|
+
* the job PAUSES: it releases its execution slot (JC-D), enqueues the request
|
|
126
|
+
* for the foreground human, and blocks. On resolution it re-acquires a slot
|
|
127
|
+
* with PRIORITY (so it is not starved by work dispatched while it waited) and
|
|
128
|
+
* returns the decision. The real prompt + this job's checkpoint snapshot happen
|
|
129
|
+
* inside `finalize` on the foreground, serialized on the shared mutex.
|
|
130
|
+
*/
|
|
131
|
+
private makeJobApproval;
|
|
132
|
+
/** Resolve a job's scope from an optional root name (fail-loud on unknown). */
|
|
133
|
+
private jobScope;
|
|
134
|
+
private statusFor;
|
|
135
|
+
private errorFor;
|
|
136
|
+
private requireJob;
|
|
137
|
+
private log;
|
|
138
|
+
private view;
|
|
139
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { runAgent } from "../agent/loop.js";
|
|
2
|
+
import { ApprovalService, classify, serializeGate, } from "../approval/index.js";
|
|
3
|
+
import { CheckpointGate, withCheckpointGate } from "../checkpoint/index.js";
|
|
4
|
+
import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, } from "../errors/index.js";
|
|
5
|
+
import { Budget, resolveBudget, scopeRegistry, } from "../subagent/index.js";
|
|
6
|
+
import { Workspace } from "../workspace/index.js";
|
|
7
|
+
import { LogBuffer } from "./log-buffer.js";
|
|
8
|
+
import { JobLogRenderer } from "./log-renderer.js";
|
|
9
|
+
import { isTerminal } from "./types.js";
|
|
10
|
+
/** Longest task excerpt kept as a job label (display, not record). */
|
|
11
|
+
const LABEL_MAX = 60;
|
|
12
|
+
/**
|
|
13
|
+
* Session-scoped background jobs (C.28). The main agent dispatches a job with
|
|
14
|
+
* `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
|
|
15
|
+
* with the foreground session, off screen (its output goes to a log buffer). A
|
|
16
|
+
* gated action pauses the job, releases its execution slot, and enqueues an
|
|
17
|
+
* approval request the foreground human services between turns; on approval the
|
|
18
|
+
* job re-acquires a slot (with priority) and runs on — its pre- AND post-pause
|
|
19
|
+
* mutations coalescing under ONE checkpoint. Cancel and session exit kill-tree a
|
|
20
|
+
* job's process tree (via its cancellation signal), leaving no orphan.
|
|
21
|
+
*
|
|
22
|
+
* The manager is session-scoped and dies with the session (NOT a daemon): nothing
|
|
23
|
+
* here persists a live registry across processes. What survives on disk is a
|
|
24
|
+
* job's checkpoint set, so `cruxy rollback <id>` still works afterwards.
|
|
25
|
+
*/
|
|
26
|
+
export class JobManager {
|
|
27
|
+
deps;
|
|
28
|
+
jobs = new Map();
|
|
29
|
+
/** In-flight executions, awaited on {@link cancelAll} so teardown completes. */
|
|
30
|
+
running = new Set();
|
|
31
|
+
idFactory;
|
|
32
|
+
now;
|
|
33
|
+
runAgentFn;
|
|
34
|
+
seq = 0;
|
|
35
|
+
constructor(deps) {
|
|
36
|
+
this.deps = deps;
|
|
37
|
+
this.now = deps.now ?? Date.now;
|
|
38
|
+
this.runAgentFn = deps.runAgentFn ?? runAgent;
|
|
39
|
+
this.idFactory =
|
|
40
|
+
deps.idFactory ??
|
|
41
|
+
(() => `job-${++this.seq}-${Math.random().toString(36).slice(2, 6)}`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Dispatch a background job (the `run_in_background` seam). Fails loud when the
|
|
45
|
+
* feature is disabled, when the live-job ceiling is reached, or when a named
|
|
46
|
+
* root is unknown — all BEFORE the job is registered, so a refused dispatch
|
|
47
|
+
* leaves no ghost. Returns a view immediately; the job runs asynchronously.
|
|
48
|
+
*/
|
|
49
|
+
dispatch(spec) {
|
|
50
|
+
const { config } = this.deps;
|
|
51
|
+
if (!config.jobs.enabled)
|
|
52
|
+
throw jobsDisabled();
|
|
53
|
+
const live = this.liveCount();
|
|
54
|
+
if (live >= config.jobs.maxJobs) {
|
|
55
|
+
throw jobLimitExceeded(config.jobs.maxJobs, live);
|
|
56
|
+
}
|
|
57
|
+
// Resolve (and validate) the job's scope up front — an unknown root name
|
|
58
|
+
// throws CRUXY_E_ROOT_UNKNOWN here, which the dispatch tool surfaces to the
|
|
59
|
+
// model, rather than failing silently inside the background run.
|
|
60
|
+
const scope = this.jobScope(spec.root);
|
|
61
|
+
const id = this.idFactory();
|
|
62
|
+
const checkpoints = config.checkpoint.enabled
|
|
63
|
+
? new CheckpointGate({
|
|
64
|
+
config,
|
|
65
|
+
primaryRoot: scope.workspace.primary().absPath,
|
|
66
|
+
})
|
|
67
|
+
: undefined;
|
|
68
|
+
const job = {
|
|
69
|
+
id,
|
|
70
|
+
spec,
|
|
71
|
+
label: taskLabel(spec.task),
|
|
72
|
+
status: "queued",
|
|
73
|
+
iterations: 0,
|
|
74
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
75
|
+
summary: "",
|
|
76
|
+
logs: new LogBuffer(config.jobs.logBufferLines),
|
|
77
|
+
controller: new AbortController(),
|
|
78
|
+
checkpoints,
|
|
79
|
+
scope,
|
|
80
|
+
holdsSlot: false,
|
|
81
|
+
};
|
|
82
|
+
this.jobs.set(id, job);
|
|
83
|
+
this.log(job, "out", `dispatched: ${job.label}`);
|
|
84
|
+
// Fire-and-forget, but TRACKED: cancelAll awaits these so no execution is
|
|
85
|
+
// left detached at session exit. `execute` never rejects (it maps every
|
|
86
|
+
// failure onto the job's status), so the catch is purely defensive.
|
|
87
|
+
const p = this.execute(job)
|
|
88
|
+
.catch((err) => {
|
|
89
|
+
job.status = "failed";
|
|
90
|
+
job.error = `${ErrorCode.Internal}: ${messageOf(err) ?? "job crashed"}`;
|
|
91
|
+
})
|
|
92
|
+
.finally(() => this.running.delete(p));
|
|
93
|
+
this.running.add(p);
|
|
94
|
+
return this.view(job);
|
|
95
|
+
}
|
|
96
|
+
/** Live view of every job this session, dispatch order. */
|
|
97
|
+
list() {
|
|
98
|
+
return [...this.jobs.values()].map((j) => this.view(j));
|
|
99
|
+
}
|
|
100
|
+
/** One job's view, or undefined if the id is unknown. */
|
|
101
|
+
get(id) {
|
|
102
|
+
const job = this.jobs.get(id);
|
|
103
|
+
return job ? this.view(job) : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** One job's full log (for `/logs <id>`). Fails loud on an unknown id. */
|
|
106
|
+
logs(id) {
|
|
107
|
+
const job = this.requireJob(id);
|
|
108
|
+
return {
|
|
109
|
+
id: job.id,
|
|
110
|
+
status: job.status,
|
|
111
|
+
lines: job.logs.snapshot(),
|
|
112
|
+
dropped: job.logs.dropped,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Cancel one job: abort its run (kill-tree'ing any in-flight process via the
|
|
117
|
+
* signal reaching `ctx.signal`) and, if it is paused, withdraw its queued
|
|
118
|
+
* approval so it stops waiting. A checkpoint it already took SURVIVES for
|
|
119
|
+
* review/rollback. Fails loud on an unknown id; a no-op on an already-terminal
|
|
120
|
+
* job (returns false).
|
|
121
|
+
*/
|
|
122
|
+
cancel(id) {
|
|
123
|
+
const job = this.requireJob(id);
|
|
124
|
+
if (isTerminal(job.status))
|
|
125
|
+
return false;
|
|
126
|
+
this.abort(job);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Cancel EVERY live job (session exit / teardown) and AWAIT their teardown, so
|
|
131
|
+
* nothing is left running or orphaned. Returns how many were cancelled — the
|
|
132
|
+
* caller prints the honest "N job(s) cancelled". Idempotent.
|
|
133
|
+
*/
|
|
134
|
+
async cancelAll(reason) {
|
|
135
|
+
let cancelled = 0;
|
|
136
|
+
for (const job of this.jobs.values()) {
|
|
137
|
+
if (!isTerminal(job.status))
|
|
138
|
+
this.log(job, "out", `cancelling: ${reason}`);
|
|
139
|
+
}
|
|
140
|
+
for (const job of this.jobs.values()) {
|
|
141
|
+
if (!isTerminal(job.status)) {
|
|
142
|
+
this.abort(job);
|
|
143
|
+
cancelled++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Await every in-flight execution so a caller (session exit) does not race
|
|
147
|
+
// ahead of the kill-tree teardown.
|
|
148
|
+
await Promise.allSettled([...this.running]);
|
|
149
|
+
return cancelled;
|
|
150
|
+
}
|
|
151
|
+
/** Jobs that are not yet terminal (queued + running + paused). */
|
|
152
|
+
liveCount() {
|
|
153
|
+
let n = 0;
|
|
154
|
+
for (const job of this.jobs.values())
|
|
155
|
+
if (!isTerminal(job.status))
|
|
156
|
+
n++;
|
|
157
|
+
return n;
|
|
158
|
+
}
|
|
159
|
+
/** Whether any job is paused awaiting a foreground approval (drive the drain). */
|
|
160
|
+
hasPendingApprovals() {
|
|
161
|
+
return this.deps.approvalQueue.hasPending();
|
|
162
|
+
}
|
|
163
|
+
/** Service every pending job approval on the foreground (called between turns). */
|
|
164
|
+
serviceApprovals() {
|
|
165
|
+
return this.deps.approvalQueue.serviceAll();
|
|
166
|
+
}
|
|
167
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
168
|
+
/** Abort a job's run and unblock it if paused (shared by cancel/cancelAll). */
|
|
169
|
+
abort(job) {
|
|
170
|
+
job.controller.abort();
|
|
171
|
+
// If paused on the queue, withdraw with a deny so its `submit` resolves and
|
|
172
|
+
// the run tears down at its next turn boundary instead of hanging.
|
|
173
|
+
this.deps.approvalQueue.withdraw(job.id, { allow: false });
|
|
174
|
+
this.log(job, "out", "cancel requested");
|
|
175
|
+
}
|
|
176
|
+
/** Drive one job to completion, mapping every outcome onto its status. */
|
|
177
|
+
async execute(job) {
|
|
178
|
+
const signal = job.controller.signal;
|
|
179
|
+
try {
|
|
180
|
+
// Wait for an execution permit — a queued job holds none until a slot frees
|
|
181
|
+
// (the shared cap, contended with subagents). This is the queued→running
|
|
182
|
+
// gate.
|
|
183
|
+
await this.deps.semaphore.acquire();
|
|
184
|
+
job.holdsSlot = true;
|
|
185
|
+
if (signal.aborted) {
|
|
186
|
+
job.status = "cancelled";
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
job.status = "running";
|
|
190
|
+
// ONE undo unit for the WHOLE job, keyed by the job id — begun here and
|
|
191
|
+
// NEVER again (a pause is not a checkpoint boundary), so pre- and post-pause
|
|
192
|
+
// mutations coalesce and `cruxy rollback <id>` reverts the whole job.
|
|
193
|
+
job.checkpoints?.beginRun(job.spec.task, job.id);
|
|
194
|
+
const result = await this.runAgentFn(this.runArgs(job, signal));
|
|
195
|
+
job.iterations = result.iterations;
|
|
196
|
+
job.usage = result.usage;
|
|
197
|
+
job.summary = lastAssistantText(result.messages);
|
|
198
|
+
job.status = this.statusFor(result, job);
|
|
199
|
+
if (job.status === "failed")
|
|
200
|
+
job.error = this.errorFor(result);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (signal.aborted) {
|
|
204
|
+
job.status = "cancelled";
|
|
205
|
+
}
|
|
206
|
+
else if (CruxyError.is(err) &&
|
|
207
|
+
err.code === ErrorCode.ApprovalRequired) {
|
|
208
|
+
// A gated action with no interactive foreground to service it — the
|
|
209
|
+
// pinned fail-loud path. The job FAILS with the coded reason; it never
|
|
210
|
+
// auto-approves.
|
|
211
|
+
job.status = "failed";
|
|
212
|
+
job.error = `${err.code}: ${err.title}`;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
job.status = "failed";
|
|
216
|
+
job.error = `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
if (job.holdsSlot) {
|
|
221
|
+
this.deps.semaphore.release();
|
|
222
|
+
job.holdsSlot = false;
|
|
223
|
+
}
|
|
224
|
+
this.log(job, "out", `job ${job.status}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
|
|
228
|
+
runArgs(job, signal) {
|
|
229
|
+
const { deps } = this;
|
|
230
|
+
const registry = scopeRegistry(deps.parentRegistry, job.spec.tools);
|
|
231
|
+
const budget = new Budget(resolveBudget(deps.config.subagent.defaultBudget, {
|
|
232
|
+
...(job.spec.budget?.maxIterations !== undefined
|
|
233
|
+
? { maxIterations: job.spec.budget.maxIterations }
|
|
234
|
+
: {}),
|
|
235
|
+
...(job.spec.budget?.maxTokens !== undefined
|
|
236
|
+
? { maxTokens: job.spec.budget.maxTokens }
|
|
237
|
+
: {}),
|
|
238
|
+
}));
|
|
239
|
+
const ctx = {
|
|
240
|
+
cwd: job.scope.cwd,
|
|
241
|
+
workspace: job.scope.workspace,
|
|
242
|
+
config: deps.config,
|
|
243
|
+
logger: deps.logger,
|
|
244
|
+
requestApproval: this.makeJobApproval(job),
|
|
245
|
+
checkpointsActive: Boolean(job.checkpoints),
|
|
246
|
+
sandbox: deps.sandbox,
|
|
247
|
+
signal,
|
|
248
|
+
};
|
|
249
|
+
const messages = [{ role: "user", content: job.spec.task }];
|
|
250
|
+
return {
|
|
251
|
+
messages,
|
|
252
|
+
provider: deps.provider,
|
|
253
|
+
registry,
|
|
254
|
+
config: deps.config,
|
|
255
|
+
ctx,
|
|
256
|
+
renderer: new JobLogRenderer((stream, text) => this.log(job, stream, text)),
|
|
257
|
+
git: deps.git,
|
|
258
|
+
projectInstructions: deps.projectInstructions,
|
|
259
|
+
subagent: true,
|
|
260
|
+
budget,
|
|
261
|
+
router: deps.router,
|
|
262
|
+
taskClass: "subagent",
|
|
263
|
+
signal,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The job's approval gate — the producer side of the pending-approval queue.
|
|
268
|
+
* A read-tier action passes freely. A mutate/destructive action with NO
|
|
269
|
+
* interactive foreground fails loud (never auto-approve, never hang). Otherwise
|
|
270
|
+
* the job PAUSES: it releases its execution slot (JC-D), enqueues the request
|
|
271
|
+
* for the foreground human, and blocks. On resolution it re-acquires a slot
|
|
272
|
+
* with PRIORITY (so it is not starved by work dispatched while it waited) and
|
|
273
|
+
* returns the decision. The real prompt + this job's checkpoint snapshot happen
|
|
274
|
+
* inside `finalize` on the foreground, serialized on the shared mutex.
|
|
275
|
+
*/
|
|
276
|
+
makeJobApproval(job) {
|
|
277
|
+
const { approvalQueue, semaphore, approvalMutex, foregroundInteractive } = this.deps;
|
|
278
|
+
const cwd = job.scope.cwd;
|
|
279
|
+
const interactive = new ApprovalService({
|
|
280
|
+
cwd,
|
|
281
|
+
interactive: foregroundInteractive,
|
|
282
|
+
io: this.deps.promptIO,
|
|
283
|
+
});
|
|
284
|
+
// The foreground-serviced decision: the interactive U.3 prompt, wrapped in
|
|
285
|
+
// THIS job's checkpoint hook, serialized on the SHARED approval mutex — so a
|
|
286
|
+
// job's prompt/checkpoint serialize against a foreground action's on the same
|
|
287
|
+
// lock, and the snapshot lands under the job's own gate.
|
|
288
|
+
const foregroundGate = serializeGate(withCheckpointGate((a) => interactive.requestApproval(a), job.checkpoints, job.scope.workspace), approvalMutex, cwd);
|
|
289
|
+
return async (action) => {
|
|
290
|
+
const req = classify(action, cwd);
|
|
291
|
+
if (req.tier === "read")
|
|
292
|
+
return { allow: true };
|
|
293
|
+
if (!foregroundInteractive)
|
|
294
|
+
throw approvalRequired(req.summary);
|
|
295
|
+
// Pause: release the slot and enqueue for the foreground human.
|
|
296
|
+
if (job.holdsSlot) {
|
|
297
|
+
semaphore.release();
|
|
298
|
+
job.holdsSlot = false;
|
|
299
|
+
}
|
|
300
|
+
job.status = "paused-needs-approval";
|
|
301
|
+
this.log(job, "out", `paused — needs approval: ${req.summary}`);
|
|
302
|
+
try {
|
|
303
|
+
return await approvalQueue.submit({
|
|
304
|
+
jobId: job.id,
|
|
305
|
+
summary: req.summary,
|
|
306
|
+
tier: req.tier,
|
|
307
|
+
finalize: () => foregroundGate(action),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
// Resume: re-take a slot with PRIORITY unless the job was cancelled while
|
|
312
|
+
// paused (in which case the run aborts at its next turn boundary and the
|
|
313
|
+
// outer finally, seeing holdsSlot=false, does not double-release).
|
|
314
|
+
if (!job.controller.signal.aborted) {
|
|
315
|
+
await semaphore.acquire({ priority: true });
|
|
316
|
+
job.holdsSlot = true;
|
|
317
|
+
job.status = "running";
|
|
318
|
+
this.log(job, "out", "resumed after approval");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Resolve a job's scope from an optional root name (fail-loud on unknown). */
|
|
324
|
+
jobScope(rootName) {
|
|
325
|
+
if (rootName === undefined) {
|
|
326
|
+
return { cwd: this.deps.cwd, workspace: this.deps.workspace };
|
|
327
|
+
}
|
|
328
|
+
const root = this.deps.workspace.rootByName(rootName); // throws ROOT_UNKNOWN
|
|
329
|
+
return {
|
|
330
|
+
cwd: root.absPath,
|
|
331
|
+
workspace: new Workspace([
|
|
332
|
+
{ name: root.name, absPath: root.absPath, primary: true },
|
|
333
|
+
]),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
statusFor(result, job) {
|
|
337
|
+
if (result.stop === "completed")
|
|
338
|
+
return "done";
|
|
339
|
+
if (result.stop === "aborted" || job.controller.signal.aborted) {
|
|
340
|
+
return "cancelled";
|
|
341
|
+
}
|
|
342
|
+
return "failed"; // budget / max_iterations — partial, honestly not "done"
|
|
343
|
+
}
|
|
344
|
+
errorFor(result) {
|
|
345
|
+
const reason = result.stop === "budget"
|
|
346
|
+
? (result.stopReason ?? "budget cap reached")
|
|
347
|
+
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
348
|
+
return `${ErrorCode.SubagentBudget}: ${reason}`;
|
|
349
|
+
}
|
|
350
|
+
requireJob(id) {
|
|
351
|
+
const job = this.jobs.get(id);
|
|
352
|
+
if (!job)
|
|
353
|
+
throw jobNotFound(id);
|
|
354
|
+
return job;
|
|
355
|
+
}
|
|
356
|
+
log(job, stream, text) {
|
|
357
|
+
job.logs.append({ atMs: this.now(), stream, text });
|
|
358
|
+
}
|
|
359
|
+
view(job) {
|
|
360
|
+
const pending = this.deps.approvalQueue.pendingFor(job.id);
|
|
361
|
+
return {
|
|
362
|
+
id: job.id,
|
|
363
|
+
status: job.status,
|
|
364
|
+
label: job.label,
|
|
365
|
+
...(job.error ? { error: job.error } : {}),
|
|
366
|
+
iterations: job.iterations,
|
|
367
|
+
usage: job.usage,
|
|
368
|
+
...(pending ? { pendingApproval: pending.summary } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** One-line task excerpt for a job label. */
|
|
373
|
+
function taskLabel(task) {
|
|
374
|
+
const flat = task.replace(/\s+/g, " ").trim();
|
|
375
|
+
return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
|
|
376
|
+
}
|
|
377
|
+
/** The final assistant text of a run — the job's summary. */
|
|
378
|
+
function lastAssistantText(messages) {
|
|
379
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
380
|
+
const msg = messages[i];
|
|
381
|
+
if (msg.role !== "assistant")
|
|
382
|
+
continue;
|
|
383
|
+
if (typeof msg.content === "string") {
|
|
384
|
+
if (msg.content.trim())
|
|
385
|
+
return msg.content.trim();
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const text = msg.content
|
|
389
|
+
.filter((block) => block.type === "text")
|
|
390
|
+
.map((block) => block.text)
|
|
391
|
+
.join("\n")
|
|
392
|
+
.trim();
|
|
393
|
+
if (text)
|
|
394
|
+
return text;
|
|
395
|
+
}
|
|
396
|
+
return "";
|
|
397
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Usage } from "@cruxy/sdk";
|
|
2
|
+
import type { LogLine } from "./log-buffer.js";
|
|
3
|
+
/**
|
|
4
|
+
* Types for session-scoped background jobs (C.28): non-interactive orchestration
|
|
5
|
+
* the main agent dispatches with `run_in_background`. A job runs the SAME agent
|
|
6
|
+
* loop as a subagent, but CONCURRENTLY with (and outliving the turn of) the
|
|
7
|
+
* foreground session — bound to the session's lifetime, nothing survives exit
|
|
8
|
+
* (NOT a daemon). Its gated actions enqueue approval requests into the one
|
|
9
|
+
* foreground queue; a paused job releases its execution slot.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A job's lifecycle state. Every state is HONEST — a job is `done` only when its
|
|
13
|
+
* run actually completed, never a fabricated success. Terminal states are `done`,
|
|
14
|
+
* `failed`, `cancelled`; the rest are live.
|
|
15
|
+
* - `queued` — dispatched, waiting for an execution slot (the shared cap is full).
|
|
16
|
+
* - `running` — holding a slot, executing its agent loop.
|
|
17
|
+
* - `paused-needs-approval` — hit a gated action; its approval is enqueued for a
|
|
18
|
+
* foreground human and it has RELEASED its slot until the human decides (JC-D).
|
|
19
|
+
* - `done` — the run completed.
|
|
20
|
+
* - `failed` — the run errored, exceeded budget, or hit a no-foreground approval
|
|
21
|
+
* wall (`CRUXY_E_APPROVAL_REQUIRED`); `error` carries the coded reason.
|
|
22
|
+
* - `cancelled` — `cruxy cancel <id>` or session exit stopped it; its process tree
|
|
23
|
+
* was kill-tree'd and any checkpoint it took survives for review/rollback.
|
|
24
|
+
*/
|
|
25
|
+
export type JobStatus = "queued" | "running" | "paused-needs-approval" | "done" | "failed" | "cancelled";
|
|
26
|
+
/** The terminal states — a job in one of these will never run again. */
|
|
27
|
+
export declare const TERMINAL_JOB_STATUSES: readonly JobStatus[];
|
|
28
|
+
/** Whether a status is terminal (no further execution). */
|
|
29
|
+
export declare function isTerminal(status: JobStatus): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* What a background job should do — mirrors the self-contained subtask shape of a
|
|
32
|
+
* subagent spawn (C.14/C.33). A job starts with NO context beyond `task`.
|
|
33
|
+
*/
|
|
34
|
+
export interface JobSpec {
|
|
35
|
+
/** The complete, self-contained task the job should perform. */
|
|
36
|
+
task: string;
|
|
37
|
+
/**
|
|
38
|
+
* Tool names to grant, resolved against the parent registry — a job can only
|
|
39
|
+
* scope DOWN. Omitted → the default read-only set. A job granted write/shell
|
|
40
|
+
* tools takes real, gated actions in the background.
|
|
41
|
+
*/
|
|
42
|
+
tools?: readonly string[];
|
|
43
|
+
/**
|
|
44
|
+
* The workspace root (by exact name, C.26) this job is confined to. Omitted →
|
|
45
|
+
* the full session workspace (read-only, or a single-root session).
|
|
46
|
+
*/
|
|
47
|
+
root?: string;
|
|
48
|
+
/** Budget overrides, clamped to `subagent.defaultBudget` (never raised). */
|
|
49
|
+
budget?: {
|
|
50
|
+
maxIterations?: number;
|
|
51
|
+
maxTokens?: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A read-only view of one job for `cruxy jobs` / the dispatch tool's result /
|
|
56
|
+
* tests. Never exposes the live control handles (abort controller, checkpoint
|
|
57
|
+
* gate) — those stay inside the manager.
|
|
58
|
+
*/
|
|
59
|
+
export interface JobView {
|
|
60
|
+
/** Stable, session-unique id (also the checkpoint run id for `cruxy rollback`). */
|
|
61
|
+
id: string;
|
|
62
|
+
status: JobStatus;
|
|
63
|
+
/** One-line task excerpt (for listings). */
|
|
64
|
+
label: string;
|
|
65
|
+
/** Present on `failed`: the coded, actionable reason. */
|
|
66
|
+
error?: string;
|
|
67
|
+
/** Model turns consumed so far. */
|
|
68
|
+
iterations: number;
|
|
69
|
+
/** Token usage accumulated so far. */
|
|
70
|
+
usage: Usage;
|
|
71
|
+
/** A one-line summary of what's pending, when `paused-needs-approval`. */
|
|
72
|
+
pendingApproval?: string;
|
|
73
|
+
}
|
|
74
|
+
/** The full log of one job, for `cruxy logs <id>`. */
|
|
75
|
+
export interface JobLog {
|
|
76
|
+
id: string;
|
|
77
|
+
status: JobStatus;
|
|
78
|
+
lines: LogLine[];
|
|
79
|
+
/** How many earlier lines rolled off the ring buffer (0 when none). */
|
|
80
|
+
dropped: number;
|
|
81
|
+
}
|