@aragon-agent/cli 0.6.31 → 0.6.33
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/CHANGELOG.md +119 -0
- package/README.md +2970 -2826
- package/dist/agent/controller.d.ts +46 -0
- package/dist/agent/controller.js +175 -7
- package/dist/agent/job-notices.d.ts +143 -0
- package/dist/agent/job-notices.js +182 -0
- package/dist/agent/reducer.d.ts +21 -1
- package/dist/agent/reducer.js +7 -0
- package/dist/agent/system-prompt.d.ts +12 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +236 -8
- package/dist/commands/builtins.js +23 -8
- package/dist/commands/perf.d.ts +77 -1
- package/dist/commands/perf.js +37 -5
- package/dist/config/cli-commands.d.ts +27 -0
- package/dist/config/cli-commands.js +54 -1
- package/dist/config/env.js +38 -0
- package/dist/config/load.js +50 -1
- package/dist/config/schema.d.ts +73 -0
- package/dist/config/schema.js +34 -0
- package/dist/config/store.js +14 -1
- package/dist/exec/events.d.ts +29 -3
- package/dist/exec/events.js +3 -12
- package/dist/exec/runner.d.ts +24 -0
- package/dist/exec/runner.js +79 -0
- package/dist/input/cursor-report.d.ts +61 -0
- package/dist/input/cursor-report.js +97 -0
- package/dist/input/stdin-filter.d.ts +22 -0
- package/dist/input/stdin-filter.js +48 -3
- package/dist/proc/limits.d.ts +76 -2
- package/dist/proc/limits.js +83 -2
- package/dist/proc/log-mirror.d.ts +59 -0
- package/dist/proc/log-mirror.js +203 -0
- package/dist/proc/log-ring.d.ts +22 -0
- package/dist/proc/log-ring.js +20 -1
- package/dist/proc/notices.d.ts +57 -0
- package/dist/proc/notices.js +101 -0
- package/dist/proc/progress.d.ts +40 -0
- package/dist/proc/progress.js +135 -0
- package/dist/proc/prompt.d.ts +24 -2
- package/dist/proc/prompt.js +51 -2
- package/dist/proc/supervisor.d.ts +35 -2
- package/dist/proc/supervisor.js +142 -43
- package/dist/proc/types.d.ts +89 -1
- package/dist/proc/wait.d.ts +57 -0
- package/dist/proc/wait.js +133 -0
- package/dist/session/persist.js +3 -1
- package/dist/session/validate-session.js +17 -0
- package/dist/tools/bash-tool.d.ts +12 -1
- package/dist/tools/bash-tool.js +100 -13
- package/dist/tools/index.d.ts +26 -2
- package/dist/tools/index.js +43 -2
- package/dist/tools/job-tools.d.ts +54 -0
- package/dist/tools/job-tools.js +329 -0
- package/dist/tools/proc-tools.d.ts +1 -1
- package/dist/tools/proc-tools.js +49 -17
- package/dist/ui/ActivityLine.js +7 -0
- package/dist/ui/App.js +98 -12
- package/dist/ui/ScrollbackShell.d.ts +62 -11
- package/dist/ui/ScrollbackShell.js +11 -3
- package/dist/ui/StatusBar.d.ts +5 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.d.ts +14 -0
- package/dist/ui/Transcript.js +14 -2
- package/dist/ui/entries/ServiceCard.d.ts +17 -1
- package/dist/ui/entries/ServiceCard.js +39 -4
- package/dist/ui/layout/status-layout.d.ts +13 -1
- package/dist/ui/layout/status-layout.js +10 -0
- package/dist/ui/layout/virtual-window.js +14 -1
- package/dist/ui/run-status-row.d.ts +12 -0
- package/dist/ui/screen.d.ts +17 -4
- package/dist/ui/screen.js +14 -6
- package/dist/ui/scrollback/LiveTail.d.ts +79 -0
- package/dist/ui/scrollback/LiveTail.js +32 -0
- package/dist/ui/scrollback/band.d.ts +114 -0
- package/dist/ui/scrollback/band.js +130 -0
- package/dist/ui/scrollback/budget.d.ts +51 -10
- package/dist/ui/scrollback/budget.js +69 -20
- package/dist/ui/scrollback/canvas.d.ts +99 -0
- package/dist/ui/scrollback/canvas.js +113 -0
- package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
- package/dist/ui/scrollback/cursor-probe.js +133 -0
- package/dist/ui/scrollback/limits.d.ts +61 -4
- package/dist/ui/scrollback/limits.js +61 -4
- package/dist/ui/scrollback/live-preview.d.ts +18 -10
- package/dist/ui/scrollback/live-preview.js +14 -11
- package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
- package/dist/ui/scrollback/scrollback-writer.js +368 -82
- package/dist/ui/scrollback/tail-window.d.ts +60 -0
- package/dist/ui/scrollback/tail-window.js +73 -0
- package/dist/ui/scrollback/use-scrollback-view.d.ts +34 -15
- package/dist/ui/scrollback/use-scrollback-view.js +110 -59
- package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
- package/dist/ui/scrollback/writer-shapes.js +53 -8
- package/package.json +3 -3
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `job_wait` and `job_list`, plus the notice tail every tool result carries
|
|
3
|
+
* (long-task-jobs §4.4.2 / §4.4.3 / §4.6).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/tools/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* WHY TWO NEW TOOLS RATHER THAN A `block` PARAMETER ON `bash_output` (D-10):
|
|
8
|
+
* the tool list IS the discoverable capability surface. A model that can SEE
|
|
9
|
+
* `job_wait` knows that waiting is a legal step in a plan; a hidden `block: 30`
|
|
10
|
+
* parameter is a feature it will never use, and extending `bash_output`'s output
|
|
11
|
+
* would also rewrite assertions that pin its current grammar word for word.
|
|
12
|
+
*
|
|
13
|
+
* EVERY RESULT IS A FIXED GRAMMAR OF AT MOST TWELVE LINES. That is not tidiness:
|
|
14
|
+
* the entire economic argument for this feature is that one wait costs ~150
|
|
15
|
+
* tokens instead of a 200-row tail, so an unbounded result would delete the
|
|
16
|
+
* reason the feature exists.
|
|
17
|
+
*/
|
|
18
|
+
import { defineTool, errorResult, textResult } from '@aragon-agent/core';
|
|
19
|
+
import { formatDuration } from '../agent/usage.js';
|
|
20
|
+
import { JOB_WAIT_MARGIN_MS, PROC_LIMITS } from '../proc/limits.js';
|
|
21
|
+
import { renderJobNotices } from '../proc/notices.js';
|
|
22
|
+
import { isTerminalStatus } from '../proc/types.js';
|
|
23
|
+
import { waitForJob } from '../proc/wait.js';
|
|
24
|
+
/**
|
|
25
|
+
* Split a page into "completed rows" and "the carry line".
|
|
26
|
+
*
|
|
27
|
+
* THE PAGE NAMES ITS CARRY: `LogRingPage.carryRow` is the not-yet-terminated line,
|
|
28
|
+
* and `rows` ends with it when it is non-empty. A page from a caller that does not
|
|
29
|
+
* provide it (a test double, `bash_output`) is treated as all-completed, which
|
|
30
|
+
* keeps this a pure refinement rather than a new requirement.
|
|
31
|
+
*/
|
|
32
|
+
function splitCarry(page) {
|
|
33
|
+
const rows = page?.rows ?? [];
|
|
34
|
+
const carry = page?.carryRow ?? '';
|
|
35
|
+
if (carry.length === 0)
|
|
36
|
+
return { rows, carry };
|
|
37
|
+
const last = rows[rows.length - 1];
|
|
38
|
+
return last === carry ? { rows: rows.slice(0, -1), carry } : { rows, carry };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Clamp `timeoutSeconds` into `[jobs.waitSeconds, 60]` and then into the tool
|
|
42
|
+
* ceiling (I-4).
|
|
43
|
+
*
|
|
44
|
+
* TWO CONVERGENCES, BOTH REQUIRED. The first honours `WAIT_SECONDS_RANGE`; the
|
|
45
|
+
* second is what makes it impossible for a wait to outlive the tool that is
|
|
46
|
+
* hosting it, which is the difference between "a bounded wait" and "another
|
|
47
|
+
* `Command timed out after 180000ms`".
|
|
48
|
+
*/
|
|
49
|
+
function clampWaitSeconds(deps, requested) {
|
|
50
|
+
const configured = deps.waitSeconds();
|
|
51
|
+
const asked = requested === undefined || !Number.isFinite(requested) || requested < PROC_LIMITS.waitMinSeconds
|
|
52
|
+
? configured
|
|
53
|
+
: requested;
|
|
54
|
+
const bounded = Math.max(PROC_LIMITS.waitMinSeconds, Math.min(Math.floor(asked), PROC_LIMITS.waitMaxSeconds));
|
|
55
|
+
const ceiling = Math.floor((deps.toolTimeoutMs() - JOB_WAIT_MARGIN_MS) / 1000);
|
|
56
|
+
// `Math.max(1, ...)` rather than `Math.max(waitMinSeconds, ...)`: when an
|
|
57
|
+
// operator sets a tool ceiling below the margin, "the wait fits inside the
|
|
58
|
+
// ceiling" is the invariant that must win. Nothing here ever silently produces
|
|
59
|
+
// a wait longer than the tool call that hosts it.
|
|
60
|
+
return Math.max(1, Math.min(bounded, ceiling));
|
|
61
|
+
}
|
|
62
|
+
/** `JOB t3 task running 12m34s` - the one header grammar for every outcome. */
|
|
63
|
+
function headerLine(job, nowMs) {
|
|
64
|
+
const elapsed = formatDuration((job.endedAt ?? nowMs) - job.startedAt);
|
|
65
|
+
const code = job.exitCode !== null
|
|
66
|
+
? ` code ${job.exitCode}`
|
|
67
|
+
: job.signal !== null
|
|
68
|
+
? ` signal ${job.signal}`
|
|
69
|
+
: '';
|
|
70
|
+
return `JOB ${job.id} ${job.kind} ${job.status}${code} ${elapsed}`;
|
|
71
|
+
}
|
|
72
|
+
/** `[stalled] ...` - only for a live job whose silence passed the threshold. */
|
|
73
|
+
function stalledLine(job, nowMs, stallSeconds) {
|
|
74
|
+
// A FINISHED JOB CANNOT BE STALLED. `snapshot()` derives the flag only for a
|
|
75
|
+
// live job, so this guard is about the OTHER direction: a snapshot captured
|
|
76
|
+
// while the job was quiet and delivered after it ended must not claim silence.
|
|
77
|
+
if (job.stalled !== true || isTerminalStatus(job.status))
|
|
78
|
+
return undefined;
|
|
79
|
+
const quiet = formatDuration(Math.max(0, nowMs - job.lastOutputAt));
|
|
80
|
+
return (`[stalled] no output for ${quiet} (threshold ${stallSeconds}s). Keep waiting, ` +
|
|
81
|
+
`stop it with bash_kill({ job: "${job.id}" }), or read the tail with bash_output.`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Assemble one job's block.
|
|
85
|
+
*
|
|
86
|
+
* THE ROW BLOCK IS THE ONLY ELASTIC PART, and it shrinks first when the fixed
|
|
87
|
+
* lines grow - which is how "at most twelve lines" survives a job that is both
|
|
88
|
+
* stalled and truncated without either sentence being dropped.
|
|
89
|
+
*/
|
|
90
|
+
function renderJobBlock(args) {
|
|
91
|
+
const { job, rows, cursor, since, nowMs } = args;
|
|
92
|
+
const terminal = isTerminalStatus(job.status);
|
|
93
|
+
const fixed = [headerLine(job, nowMs)];
|
|
94
|
+
const progress = job.progress?.text;
|
|
95
|
+
if (progress !== undefined) {
|
|
96
|
+
fixed.push(`[progress] ${progress}${args.progressUnchanged ? ' (unchanged)' : ''}`);
|
|
97
|
+
}
|
|
98
|
+
const stalled = stalledLine(job, nowMs, args.stalledSeconds);
|
|
99
|
+
if (stalled !== undefined)
|
|
100
|
+
fixed.push(stalled);
|
|
101
|
+
if (args.truncatedNote !== undefined)
|
|
102
|
+
fixed.push(args.truncatedNote);
|
|
103
|
+
const tail = [`[cursor ${cursor}]`];
|
|
104
|
+
if (terminal) {
|
|
105
|
+
tail.push(`[done] the job finished. Read more with bash_output({ service: "${job.id}" }) if you need it.`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const quiet = formatDuration(Math.max(0, nowMs - job.lastOutputAt));
|
|
109
|
+
tail.push(`[wait ${args.waitSeconds}s] still running. No output for ${quiet}.`);
|
|
110
|
+
tail.push(`[next] job_wait({ job: "${job.id}", since: ${cursor} }) to keep waiting, ` +
|
|
111
|
+
'or continue with other work.');
|
|
112
|
+
}
|
|
113
|
+
// TWELVE LINES, AND THE ROW BLOCK IS THE ONLY ELASTIC PART. Its header costs a
|
|
114
|
+
// line of its own, so the budget is computed with it already deducted; a stalled
|
|
115
|
+
// or truncated job therefore loses output rows rather than the sentence that
|
|
116
|
+
// says what is wrong.
|
|
117
|
+
const rowLineHint = rows.length > 0 ? 1 : 0;
|
|
118
|
+
const budget = Math.max(0, 12 - fixed.length - tail.length - rowLineHint);
|
|
119
|
+
const shown = rows.slice(-Math.min(PROC_LIMITS.waitTailRows, budget));
|
|
120
|
+
const sinceClause = since === undefined ? '' : ` since ${since}`;
|
|
121
|
+
return [
|
|
122
|
+
...fixed,
|
|
123
|
+
...(shown.length > 0 ? [`[... ${shown.length} row(s)${sinceClause} ...]`] : []),
|
|
124
|
+
...shown.map((row) => ` ${row}`),
|
|
125
|
+
...tail,
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
/** The three-state "no such id" answer (RV-P2-7). */
|
|
129
|
+
function unknownJobMessage(deps, requested) {
|
|
130
|
+
// RECLAIMED FIRST, AND THAT ORDER IS THE POINT (RV-P2-7). State two is the one
|
|
131
|
+
// that costs money if it is missed: a model told `No job "t9"` about a
|
|
132
|
+
// forty-minute training run that FINISHED will start it again. The live-id list
|
|
133
|
+
// is only useful once we know the id never named a job this session.
|
|
134
|
+
const reclaimed = deps.terminalRecord?.(requested);
|
|
135
|
+
if (reclaimed) {
|
|
136
|
+
return errorResult(`Job "${requested}" already finished (${reclaimed.kind} ${reclaimed.status}) and was ` +
|
|
137
|
+
'reclaimed; its outcome was delivered in a <job_notices> block. Do not re-run it.');
|
|
138
|
+
}
|
|
139
|
+
const live = deps.supervisor
|
|
140
|
+
.list()
|
|
141
|
+
.filter((job) => !isTerminalStatus(job.status))
|
|
142
|
+
.slice(0, PROC_LIMITS.listRows)
|
|
143
|
+
.map((job) => `${job.id} (${job.status} ${formatDuration(Date.now() - job.startedAt)})`);
|
|
144
|
+
if (live.length > 0) {
|
|
145
|
+
return errorResult(`No job "${requested}". Live: ${live.join(', ')}. ` +
|
|
146
|
+
'Call job_list for detail.');
|
|
147
|
+
}
|
|
148
|
+
return errorResult(`No job "${requested}". Start one with bash({ command, background: true }).`);
|
|
149
|
+
}
|
|
150
|
+
function isLive(job) {
|
|
151
|
+
return !isTerminalStatus(job.status);
|
|
152
|
+
}
|
|
153
|
+
export function makeJobWait(deps) {
|
|
154
|
+
const memory = new Map();
|
|
155
|
+
return defineTool({
|
|
156
|
+
name: 'job_wait',
|
|
157
|
+
label: 'Wait for job',
|
|
158
|
+
description: 'Wait a bounded time for a background job (service or task) and return its ' +
|
|
159
|
+
'status, one line of progress, and only the output since `since`. Returns ' +
|
|
160
|
+
'immediately when the job is already finished. Waiting is bounded (5-60s), ' +
|
|
161
|
+
'so call it again only after deciding what to do.',
|
|
162
|
+
parameters: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
job: { type: 'string', description: '"t3", "s1", or "all".' },
|
|
166
|
+
timeoutSeconds: {
|
|
167
|
+
type: 'number',
|
|
168
|
+
description: '5-60. Omit for the configured default.',
|
|
169
|
+
},
|
|
170
|
+
since: {
|
|
171
|
+
type: 'number',
|
|
172
|
+
description: 'Cursor from a previous call; omit for the smallest useful window.',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
required: ['job'],
|
|
176
|
+
},
|
|
177
|
+
async execute(_id, rawParams, ctx) {
|
|
178
|
+
const params = rawParams;
|
|
179
|
+
const requested = String(params.job);
|
|
180
|
+
const targets = requested === 'all'
|
|
181
|
+
? deps.supervisor.list().filter(isLive).map((job) => job.id)
|
|
182
|
+
: [requested];
|
|
183
|
+
if (requested !== 'all' && !deps.supervisor.get(requested)) {
|
|
184
|
+
return unknownJobMessage(deps, requested);
|
|
185
|
+
}
|
|
186
|
+
if (targets.length === 0) {
|
|
187
|
+
return textResult('No background jobs are running. Start one with ' +
|
|
188
|
+
'bash({ command, background: true }).');
|
|
189
|
+
}
|
|
190
|
+
const waitSeconds = clampWaitSeconds(deps, params.timeoutSeconds);
|
|
191
|
+
const outcome = await waitForJob(deps.supervisor, {
|
|
192
|
+
targets,
|
|
193
|
+
timeoutMs: waitSeconds * 1000,
|
|
194
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
195
|
+
...(params.since !== undefined ? { since: params.since } : {}),
|
|
196
|
+
});
|
|
197
|
+
if (outcome.aborted) {
|
|
198
|
+
return errorResult(`The wait on ${targets.join(', ')} was aborted after ` +
|
|
199
|
+
`${formatDuration(outcome.waitedMs)}. The job was not stopped; ` +
|
|
200
|
+
'call job_wait again when you want to keep waiting.');
|
|
201
|
+
}
|
|
202
|
+
const nowMs = Date.now();
|
|
203
|
+
const stallSeconds = deps.stallSeconds();
|
|
204
|
+
const blocks = [];
|
|
205
|
+
for (const job of outcome.jobs) {
|
|
206
|
+
const page = outcome.pages.get(job.id);
|
|
207
|
+
// THE CARRY LINE IS NOT AN INCREMENT WHEN IT HAS NOT MOVED (RV-P1-5). A
|
|
208
|
+
// carriage-return-rewritten progress bar completes no rows, so the ring
|
|
209
|
+
// hands the same line back on every wait: reprinting it would spend the
|
|
210
|
+
// six-row budget on one unchanging sentence, and counting it as new output
|
|
211
|
+
// would reset the spin guard so a genuinely stalled job never earns its
|
|
212
|
+
// `[guard]` line. It still reports through `[progress] (unchanged)`.
|
|
213
|
+
const { rows: rowsNew, carry } = splitCarry(page);
|
|
214
|
+
const carryUnchanged = carry.length > 0 && carry === memory.get(job.id)?.lastCarry;
|
|
215
|
+
const carryMoved = carry.length > 0 && !carryUnchanged;
|
|
216
|
+
const shownRows = carryMoved ? [...rowsNew, carry] : rowsNew;
|
|
217
|
+
const memoryForJob = memory.get(job.id) ?? { noChange: 0 };
|
|
218
|
+
const previous = memoryForJob.lastProgress;
|
|
219
|
+
const current = job.progress?.text;
|
|
220
|
+
// TWO DIFFERENT QUESTIONS, deliberately kept apart:
|
|
221
|
+
// - `progressUnchanged` is a RENDERING decision (print `(unchanged)`);
|
|
222
|
+
// - `advanced` is the SPIN-GUARD decision, and the first wait of a job is
|
|
223
|
+
// always "advanced" because it establishes the baseline. Counting it
|
|
224
|
+
// otherwise would put the guard line on the sixth wait instead of the
|
|
225
|
+
// seventh (AC-20).
|
|
226
|
+
const hasBaseline = previous !== undefined;
|
|
227
|
+
const progressUnchanged = hasBaseline && current === previous;
|
|
228
|
+
const newRows = shownRows.length;
|
|
229
|
+
const advanced = !hasBaseline || newRows > 0 || !progressUnchanged;
|
|
230
|
+
memoryForJob.noChange = advanced ? 0 : memoryForJob.noChange + 1;
|
|
231
|
+
if (current !== undefined)
|
|
232
|
+
memoryForJob.lastProgress = current;
|
|
233
|
+
if (carry.length > 0)
|
|
234
|
+
memoryForJob.lastCarry = carry;
|
|
235
|
+
memory.set(job.id, memoryForJob);
|
|
236
|
+
// THE SPIN GUARD (R-3): counted per job, reset by ANY change. It never
|
|
237
|
+
// refuses the wait; it prepends one line naming four ways out.
|
|
238
|
+
const guard = memoryForJob.noChange >= deps.maxConsecutiveWaits
|
|
239
|
+
? [
|
|
240
|
+
`[guard] ${memoryForJob.noChange} waits with no change. This job may be stalled. ` +
|
|
241
|
+
'Options: keep waiting, stop it (bash_kill), read the tail (bash_output), or ask the user.',
|
|
242
|
+
]
|
|
243
|
+
: [];
|
|
244
|
+
blocks.push([
|
|
245
|
+
...guard,
|
|
246
|
+
...renderJobBlock({
|
|
247
|
+
job,
|
|
248
|
+
rows: shownRows,
|
|
249
|
+
cursor: page?.cursor ?? job.rowsSeen,
|
|
250
|
+
since: params.since,
|
|
251
|
+
nowMs,
|
|
252
|
+
stalledSeconds: stallSeconds,
|
|
253
|
+
waitSeconds,
|
|
254
|
+
progressUnchanged,
|
|
255
|
+
truncatedNote: page?.truncated === true
|
|
256
|
+
? '[note] older rows were dropped - the tail keeps the last ' +
|
|
257
|
+
`${PROC_LIMITS.serviceTailRows} rows` +
|
|
258
|
+
(job.logPath ? `. Full log: ${job.logPath}` : '.')
|
|
259
|
+
: undefined,
|
|
260
|
+
}),
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
const shown = blocks.slice(0, PROC_LIMITS.listRows);
|
|
264
|
+
const lines = shown.flat();
|
|
265
|
+
if (blocks.length > shown.length) {
|
|
266
|
+
lines.push(`[... ${blocks.length - shown.length} more job(s): job_list ...]`);
|
|
267
|
+
}
|
|
268
|
+
return textResult(lines.join('\n'));
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
export function makeJobList(deps) {
|
|
273
|
+
return defineTool({
|
|
274
|
+
name: 'job_list',
|
|
275
|
+
label: 'List jobs',
|
|
276
|
+
description: 'One line per background job: id, kind, status, elapsed, progress. Use it ' +
|
|
277
|
+
'when you need the id you started.',
|
|
278
|
+
parameters: { type: 'object', properties: {} },
|
|
279
|
+
async execute() {
|
|
280
|
+
const jobs = deps.supervisor.list();
|
|
281
|
+
if (jobs.length === 0) {
|
|
282
|
+
return textResult('No background jobs. Start one with bash({ command, background: true }).');
|
|
283
|
+
}
|
|
284
|
+
const nowMs = Date.now();
|
|
285
|
+
// LIVE FIRST, then by start time - the same trade `selectPanelRows` makes
|
|
286
|
+
// for the team panel: what is running is what the reader wants.
|
|
287
|
+
const ordered = [...jobs].sort((a, b) => {
|
|
288
|
+
const liveDelta = Number(isLive(b)) - Number(isLive(a));
|
|
289
|
+
return liveDelta !== 0 ? liveDelta : a.startedAt - b.startedAt;
|
|
290
|
+
});
|
|
291
|
+
const rows = ordered.slice(0, PROC_LIMITS.listRows).map((job) => oneRow(job, nowMs));
|
|
292
|
+
const liveCount = jobs.filter(isLive).length;
|
|
293
|
+
const serviceCount = jobs.filter((job) => job.kind === 'service').length;
|
|
294
|
+
const taskCount = jobs.length - serviceCount;
|
|
295
|
+
const summary = `${liveCount} live of ${jobs.length} (${taskCount} task, ${serviceCount} service). ` +
|
|
296
|
+
'Wait: job_wait({ job: "<id>" }). Full tail: bash_output({ service: "<id>", since: <cursor> }).';
|
|
297
|
+
if (ordered.length > rows.length)
|
|
298
|
+
rows.push(`[+${ordered.length - rows.length} more]`);
|
|
299
|
+
return textResult([...rows, summary].join('\n'));
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/** `t3 task running 12m34s 47%` - one line, fixed column count. */
|
|
304
|
+
function oneRow(job, nowMs) {
|
|
305
|
+
const elapsed = formatDuration((job.endedAt ?? nowMs) - job.startedAt);
|
|
306
|
+
const aside = job.progress?.text ?? job.url ?? '';
|
|
307
|
+
return `${job.id} ${job.kind} ${job.status} ${elapsed}${aside ? ` ${aside}` : ''}`;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Append pending `<job_notices>` to every tool result (the PULL channel).
|
|
311
|
+
*
|
|
312
|
+
* SAME SHAPE AS `withMailboxTail`, AND FOR THE SAME REASON: the engine is told
|
|
313
|
+
* nothing, the wrapper rides `{...tool, execute}`, and the notice is delivered on
|
|
314
|
+
* the next tool call whatever that call is. The queue is drained EVEN WHEN the
|
|
315
|
+
* wrapped tool errored - a refused `bash` is still a delivery opportunity.
|
|
316
|
+
*/
|
|
317
|
+
export function withJobNoticeTail(tool, drain) {
|
|
318
|
+
const original = tool.execute;
|
|
319
|
+
return {
|
|
320
|
+
...tool,
|
|
321
|
+
async execute(id, params, ctx) {
|
|
322
|
+
const result = await original(id, params, ctx);
|
|
323
|
+
const block = renderJobNotices(drain());
|
|
324
|
+
if (block.length === 0)
|
|
325
|
+
return result;
|
|
326
|
+
return { ...result, content: [...result.content, { type: 'text', text: block }] };
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* command is still arbitrary.
|
|
18
18
|
*/
|
|
19
19
|
import { type AgentTool } from '@aragon-agent/core';
|
|
20
|
-
import type
|
|
20
|
+
import { type ProcSupervisorPort } from '../proc/types.js';
|
|
21
21
|
export interface ProcToolDeps {
|
|
22
22
|
supervisor: ProcSupervisorPort;
|
|
23
23
|
}
|
package/dist/tools/proc-tools.js
CHANGED
|
@@ -18,9 +18,16 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { defineTool, errorResult, textResult } from '@aragon-agent/core';
|
|
20
20
|
import { PROC_LIMITS } from '../proc/limits.js';
|
|
21
|
-
|
|
21
|
+
import { isTerminalStatus } from '../proc/types.js';
|
|
22
|
+
/**
|
|
23
|
+
* One line of status, shared by both tools so the two can never disagree.
|
|
24
|
+
*
|
|
25
|
+
* IT NAMES THE KIND, because `s1` and `t3` are two different things to be holding
|
|
26
|
+
* and the model has to know which one it is looking at to decide whether waiting
|
|
27
|
+
* is even meaningful.
|
|
28
|
+
*/
|
|
22
29
|
function statusLine(service) {
|
|
23
|
-
const bits = [
|
|
30
|
+
const bits = [`${service.kind} ${service.id} ${service.status}`];
|
|
24
31
|
if (service.url)
|
|
25
32
|
bits.push(service.url);
|
|
26
33
|
if (service.exitCode !== null)
|
|
@@ -31,18 +38,30 @@ function statusLine(service) {
|
|
|
31
38
|
bits.push('(may have left a detached child)');
|
|
32
39
|
return `${bits.join(' ')}\n$ ${service.command}`;
|
|
33
40
|
}
|
|
41
|
+
/** `[progress] 47%` and `[stalled] ...` - the two lines worth adding to a tail. */
|
|
42
|
+
function progressLines(service) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
if (service.progress)
|
|
45
|
+
lines.push(`[progress] ${service.progress.text}`);
|
|
46
|
+
if (service.stalled === true) {
|
|
47
|
+
lines.push('[stalled] no output for a while - it has not failed. Keep waiting, stop it ' +
|
|
48
|
+
`with bash_kill({ service: "${service.id}" }), or read the tail.`);
|
|
49
|
+
}
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
34
52
|
export function makeBashOutput(deps) {
|
|
35
53
|
return defineTool({
|
|
36
54
|
name: 'bash_output',
|
|
37
|
-
label: '
|
|
38
|
-
description: 'Read a background
|
|
55
|
+
label: 'Job log',
|
|
56
|
+
description: 'Read a background job log tail and its current status. Pass the ' +
|
|
39
57
|
'`since` cursor returned by a previous call to read only what is new. ' +
|
|
40
|
-
'
|
|
41
|
-
'test watcher
|
|
58
|
+
'Accepts both ids: "s1" for a service and "t3" for a task. Use this to ' +
|
|
59
|
+
'verify a service actually started, and to read a build or test watcher ' +
|
|
60
|
+
'as it runs.',
|
|
42
61
|
parameters: {
|
|
43
62
|
type: 'object',
|
|
44
63
|
properties: {
|
|
45
|
-
service: { type: 'string', description: 'The
|
|
64
|
+
service: { type: 'string', description: 'The job id: "s1" or "t3".' },
|
|
46
65
|
since: {
|
|
47
66
|
type: 'number',
|
|
48
67
|
description: 'Cursor from a previous call; omit to read the whole tail.',
|
|
@@ -54,13 +73,18 @@ export function makeBashOutput(deps) {
|
|
|
54
73
|
const params = rawParams;
|
|
55
74
|
const service = deps.supervisor.get(params.service);
|
|
56
75
|
if (!service) {
|
|
57
|
-
return errorResult(`No
|
|
76
|
+
return errorResult(`No job "${params.service}". Start one with bash({ command, background: true }).`);
|
|
58
77
|
}
|
|
59
78
|
const page = deps.supervisor.read(params.service, params.since);
|
|
60
|
-
const lines = [statusLine(service)];
|
|
79
|
+
const lines = [statusLine(service), ...progressLines(service)];
|
|
61
80
|
if (page?.truncated) {
|
|
81
|
+
// THE PATH IS THE ESCAPE HATCH (long-task-jobs §4.8). Past 200 rows the
|
|
82
|
+
// ring has forgotten the error the model needs, and the full log is on
|
|
83
|
+
// disk; naming it lets `grep`/`read_file` answer a question this buffer
|
|
84
|
+
// no longer can.
|
|
62
85
|
lines.push(`[note] older rows were dropped - the tail keeps the last ` +
|
|
63
|
-
`${PROC_LIMITS.serviceTailRows} rows
|
|
86
|
+
`${PROC_LIMITS.serviceTailRows} rows` +
|
|
87
|
+
(service.logPath ? `. Full log: ${service.logPath}` : '.'));
|
|
64
88
|
}
|
|
65
89
|
if (!page || page.rows.length === 0) {
|
|
66
90
|
lines.push('(no new output)');
|
|
@@ -76,27 +100,35 @@ export function makeBashOutput(deps) {
|
|
|
76
100
|
export function makeBashKill(deps) {
|
|
77
101
|
return defineTool({
|
|
78
102
|
name: 'bash_kill',
|
|
79
|
-
label: 'Stop
|
|
80
|
-
description: 'Stop a background service by id, or every
|
|
81
|
-
'
|
|
82
|
-
'asked
|
|
103
|
+
label: 'Stop job',
|
|
104
|
+
description: 'Stop a background job (service or task) by id, or every job with "all". ' +
|
|
105
|
+
'Stop the jobs you started once you are finished with them, unless the ' +
|
|
106
|
+
'user asked them to stay up.',
|
|
83
107
|
parameters: {
|
|
84
108
|
type: 'object',
|
|
85
109
|
properties: {
|
|
86
|
-
service: { type: 'string', description: 'The
|
|
110
|
+
service: { type: 'string', description: 'The job id ("s1" or "t3"), or "all".' },
|
|
87
111
|
},
|
|
88
112
|
required: ['service'],
|
|
89
113
|
},
|
|
90
114
|
async execute(_id, rawParams) {
|
|
91
115
|
const params = rawParams;
|
|
92
116
|
if (params.service !== 'all' && !deps.supervisor.get(params.service)) {
|
|
93
|
-
return errorResult(`No
|
|
117
|
+
return errorResult(`No job "${params.service}".`);
|
|
94
118
|
}
|
|
95
119
|
const stopped = await deps.supervisor.stop(params.service);
|
|
96
120
|
if (stopped.length === 0)
|
|
97
121
|
return textResult('Nothing to stop.');
|
|
98
122
|
const lines = stopped.map((s) => statusLine(s));
|
|
99
|
-
|
|
123
|
+
// A JOB THAT HAD ALREADY FINISHED IS NOT A STOP (AC-37). `stop()` returns
|
|
124
|
+
// the snapshot either way, so the count has to exclude terminal records or
|
|
125
|
+
// `bash_kill` reports having killed something that ended ten minutes ago.
|
|
126
|
+
const ended = stopped.filter((s) => isTerminalStatus(s.status)).length;
|
|
127
|
+
const killed = stopped.length - ended;
|
|
128
|
+
const summary = killed === 0
|
|
129
|
+
? `Nothing to stop - ${stopped.length === 1 ? 'it had' : 'they had'} already finished.`
|
|
130
|
+
: `Stopped ${killed} job${killed === 1 ? '' : 's'}.`;
|
|
131
|
+
return textResult(`${summary}\n${lines.join('\n')}`);
|
|
100
132
|
},
|
|
101
133
|
});
|
|
102
134
|
}
|
package/dist/ui/ActivityLine.js
CHANGED
|
@@ -10,6 +10,13 @@ export function liveSpinner(reducedMotion, caps) {
|
|
|
10
10
|
}
|
|
11
11
|
/** Explicit execution facts outrank compatibility tool names and rotating phrases. */
|
|
12
12
|
export function resolveActivityLabel(activity, glyphs) {
|
|
13
|
+
// A WAIT OUTRANKS EVERYTHING BELOW IT, and sits ABOVE `runningTool` on purpose:
|
|
14
|
+
// "Running job_wait" names the tool, "Waiting on t3 (up to 30s)" names the
|
|
15
|
+
// situation. It is placed after the phase/outcome branch because a settled run
|
|
16
|
+
// must never claim to be waiting for anything.
|
|
17
|
+
if (activity.phase === undefined && activity.outcome === undefined && activity.wait) {
|
|
18
|
+
return `Waiting on ${activity.wait.jobId} (up to ${activity.wait.upToSeconds}s)`;
|
|
19
|
+
}
|
|
13
20
|
if (activity.phase !== undefined || activity.activeTool !== undefined || activity.outcome !== undefined) {
|
|
14
21
|
return phaseLabels({
|
|
15
22
|
phase: activity.phase ?? (activity.activeTool ? 'tool' : 'idle'),
|