@aragon-agent/cli 0.6.31 → 0.6.32
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 +100 -0
- package/README.md +2957 -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 +232 -8
- package/dist/commands/builtins.js +23 -8
- package/dist/commands/perf.d.ts +54 -1
- package/dist/commands/perf.js +28 -4
- 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 +64 -6
- package/dist/ui/ScrollbackShell.d.ts +30 -4
- package/dist/ui/ScrollbackShell.js +8 -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 +64 -0
- package/dist/ui/scrollback/LiveTail.js +27 -0
- package/dist/ui/scrollback/band.d.ts +105 -0
- package/dist/ui/scrollback/band.js +109 -0
- package/dist/ui/scrollback/budget.d.ts +32 -3
- package/dist/ui/scrollback/budget.js +43 -6
- 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 +49 -0
- package/dist/ui/scrollback/limits.js +49 -0
- package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
- package/dist/ui/scrollback/scrollback-writer.js +368 -82
- package/dist/ui/scrollback/use-scrollback-view.d.ts +11 -0
- package/dist/ui/scrollback/use-scrollback-view.js +37 -6
- 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,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional on-disk log mirror — the escape hatch (long-task-jobs §4.8).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* WHY IT EXISTS: the ring holds 200 rows, so the `ModuleNotFoundError` from eight
|
|
7
|
+
* minutes ago is gone exactly when the model needs it. The answer is not a bigger
|
|
8
|
+
* ring (that is a bigger context) and not a new tool (the model already has
|
|
9
|
+
* `read_file` and `grep`): it is the full log on disk plus the PATH in the
|
|
10
|
+
* truncation note.
|
|
11
|
+
*
|
|
12
|
+
* THREE RULES, each of which is about not becoming a new failure mode:
|
|
13
|
+
*
|
|
14
|
+
* - ASYNC AND COALESCED. `appendFile` with a <=4 writes/second buffer, so a
|
|
15
|
+
* webpack build emitting thousands of lines cannot turn logging into the
|
|
16
|
+
* bottleneck of the build it is logging.
|
|
17
|
+
* - BOUNDED. `PROC_LIMITS.logMirrorMaxBytes` (2 MB) per job, then one
|
|
18
|
+
* `[mirror truncated]` line and silence.
|
|
19
|
+
* - FAILURES ARE DEBUG LOGS, NEVER THROWS. A full disk must not kill a training
|
|
20
|
+
* run; the ring, the tools and the job state machine do not depend on this
|
|
21
|
+
* module at all.
|
|
22
|
+
*/
|
|
23
|
+
import { appendFile, appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { getLogger } from '../logging/logger.js';
|
|
27
|
+
import { PROC_LIMITS } from './limits.js';
|
|
28
|
+
/**
|
|
29
|
+
* Delete all but the newest `PROC_LIMITS.mirrorRetainDirs` session directories.
|
|
30
|
+
*
|
|
31
|
+
* BEST EFFORT AND SILENT. This runs once per process at first use, and its whole
|
|
32
|
+
* job is to stop `~/.aragon-agent/jobs/` from growing without bound; a directory
|
|
33
|
+
* that cannot be read is not a reason to refuse to start.
|
|
34
|
+
*/
|
|
35
|
+
export function pruneJobLogSessions(jobsRoot) {
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(jobsRoot))
|
|
38
|
+
return;
|
|
39
|
+
const dirs = readdirSync(jobsRoot, { withFileTypes: true })
|
|
40
|
+
.filter((entry) => entry.isDirectory())
|
|
41
|
+
.map((entry) => {
|
|
42
|
+
const path = join(jobsRoot, entry.name);
|
|
43
|
+
try {
|
|
44
|
+
return { path, mtime: statSync(path).mtimeMs };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { path, mtime: 0 };
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
51
|
+
for (const stale of dirs.slice(PROC_LIMITS.mirrorRetainDirs)) {
|
|
52
|
+
try {
|
|
53
|
+
rmSync(stale.path, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
getLogger().debug('tool', 'job_mirror_prune_failed', {
|
|
57
|
+
path: stale.path,
|
|
58
|
+
error: err instanceof Error ? err.message : String(err),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
getLogger().debug('tool', 'job_mirror_prune_failed', {
|
|
65
|
+
root: jobsRoot,
|
|
66
|
+
error: err instanceof Error ? err.message : String(err),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The longest prefix of `text` whose UTF-8 encoding fits in `maxBytes`.
|
|
72
|
+
*
|
|
73
|
+
* THE CAP IS IN BYTES AND THE BUFFER IS IN CHARACTERS, so slicing by character
|
|
74
|
+
* count silently overshoots for any non-ASCII output - a log that writes more than
|
|
75
|
+
* its documented ceiling while claiming to enforce it. A partial trailing sequence
|
|
76
|
+
* is dropped rather than written as a replacement character, so `grep` sees a short
|
|
77
|
+
* final line instead of a corrupted one. The ASCII fast path costs one length check.
|
|
78
|
+
*/
|
|
79
|
+
function truncateToBytes(text, maxBytes) {
|
|
80
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes)
|
|
81
|
+
return text;
|
|
82
|
+
const decoded = Buffer.from(text, 'utf8').subarray(0, maxBytes).toString('utf8');
|
|
83
|
+
// A cut inside a multi-byte sequence decodes to U+FFFD, which must not be
|
|
84
|
+
// written: strip the tail of replacement characters the cut produced.
|
|
85
|
+
return decoded.replace(/\uFFFD+$/u, '');
|
|
86
|
+
}
|
|
87
|
+
export class JobLogMirror {
|
|
88
|
+
opts;
|
|
89
|
+
files = new Map();
|
|
90
|
+
buffers = new Map();
|
|
91
|
+
flushTimer;
|
|
92
|
+
pruned = false;
|
|
93
|
+
disposed = false;
|
|
94
|
+
constructor(opts) {
|
|
95
|
+
this.opts = opts;
|
|
96
|
+
}
|
|
97
|
+
/** Lazy creation: the directory appears only when a job actually prints. */
|
|
98
|
+
open(jobId) {
|
|
99
|
+
if (this.disposed || this.files.has(jobId))
|
|
100
|
+
return;
|
|
101
|
+
const path = join(this.opts.root(), `${jobId}.log`);
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
104
|
+
// SEEDED FROM DISK, because a resumed session reuses nothing but the
|
|
105
|
+
// directory name: without this the byte cap restarts at zero every launch.
|
|
106
|
+
const bytes = existsSync(path) ? statSync(path).size : 0;
|
|
107
|
+
this.files.set(jobId, { path, bytes, capped: bytes >= this.maxBytes() });
|
|
108
|
+
if (!this.pruned) {
|
|
109
|
+
this.pruned = true;
|
|
110
|
+
pruneJobLogSessions(dirname(this.opts.root()));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
getLogger().debug('tool', 'job_mirror_open_failed', {
|
|
115
|
+
job: jobId,
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Buffer one chunk. Never throws, never awaits, never blocks the child. */
|
|
121
|
+
write(jobId, chunk) {
|
|
122
|
+
if (this.disposed)
|
|
123
|
+
return;
|
|
124
|
+
if (!this.files.has(jobId))
|
|
125
|
+
this.open(jobId);
|
|
126
|
+
const file = this.files.get(jobId);
|
|
127
|
+
if (!file || file.capped)
|
|
128
|
+
return;
|
|
129
|
+
this.buffers.set(jobId, (this.buffers.get(jobId) ?? '') + chunk);
|
|
130
|
+
this.scheduleFlush();
|
|
131
|
+
}
|
|
132
|
+
pathOf(jobId) {
|
|
133
|
+
return this.files.get(jobId)?.path;
|
|
134
|
+
}
|
|
135
|
+
/** Flush what is buffered and release the timer. Idempotent. */
|
|
136
|
+
dispose() {
|
|
137
|
+
this.disposed = true;
|
|
138
|
+
if (this.flushTimer)
|
|
139
|
+
clearTimeout(this.flushTimer);
|
|
140
|
+
this.flushTimer = undefined;
|
|
141
|
+
// SYNCHRONOUS AT TEARDOWN. The async path is right during a run (it must never
|
|
142
|
+
// block the child's stdout callback), but a dispose that returns before the
|
|
143
|
+
// last chunk has landed is a log with a hole in the one place it matters -
|
|
144
|
+
// the end of the job.
|
|
145
|
+
this.flushAll(true);
|
|
146
|
+
}
|
|
147
|
+
maxBytes() {
|
|
148
|
+
return this.opts.maxBytes ?? PROC_LIMITS.logMirrorMaxBytes;
|
|
149
|
+
}
|
|
150
|
+
flushMs() {
|
|
151
|
+
return this.opts.flushMs ?? PROC_LIMITS.mirrorFlushMs;
|
|
152
|
+
}
|
|
153
|
+
scheduleFlush() {
|
|
154
|
+
if (this.flushTimer)
|
|
155
|
+
return;
|
|
156
|
+
this.flushTimer = setTimeout(() => {
|
|
157
|
+
this.flushTimer = undefined;
|
|
158
|
+
this.flushAll();
|
|
159
|
+
}, this.flushMs());
|
|
160
|
+
this.flushTimer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
flushAll(sync = false) {
|
|
163
|
+
for (const [jobId, chunk] of [...this.buffers]) {
|
|
164
|
+
this.buffers.delete(jobId);
|
|
165
|
+
this.flushOne(jobId, chunk, sync);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
flushOne(jobId, chunk, sync) {
|
|
169
|
+
const file = this.files.get(jobId);
|
|
170
|
+
if (!file || chunk.length === 0)
|
|
171
|
+
return;
|
|
172
|
+
const room = this.maxBytes() - file.bytes;
|
|
173
|
+
const accepted = room <= 0 ? '' : truncateToBytes(chunk, room);
|
|
174
|
+
if (accepted.length > 0) {
|
|
175
|
+
file.bytes += Buffer.byteLength(accepted, 'utf8');
|
|
176
|
+
this.writeOut(file.path, accepted, jobId, sync);
|
|
177
|
+
}
|
|
178
|
+
if (file.bytes >= this.maxBytes() && !file.capped) {
|
|
179
|
+
file.capped = true;
|
|
180
|
+
this.writeOut(file.path, '\n[mirror truncated]\n', jobId, sync);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** One place that writes, so the sync and async paths cannot drift. */
|
|
184
|
+
writeOut(path, text, jobId, sync) {
|
|
185
|
+
if (sync) {
|
|
186
|
+
try {
|
|
187
|
+
appendFileSync(path, text, 'utf8');
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
getLogger().debug('tool', 'job_mirror_write_failed', {
|
|
191
|
+
job: jobId,
|
|
192
|
+
error: err instanceof Error ? err.message : String(err),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
appendFile(path, text, 'utf8', (err) => {
|
|
198
|
+
if (err) {
|
|
199
|
+
getLogger().debug('tool', 'job_mirror_write_failed', { job: jobId, error: err.message });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
package/dist/proc/log-ring.d.ts
CHANGED
|
@@ -24,6 +24,17 @@ export interface LogRingPage {
|
|
|
24
24
|
cursor: number;
|
|
25
25
|
/** Rows between `since` and the first row returned were evicted. */
|
|
26
26
|
truncated: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The not-yet-terminated line, or an empty string when the last chunk ended
|
|
29
|
+
* with a newline.
|
|
30
|
+
*
|
|
31
|
+
* THE ROWS ARRAY ENDS WITH THE CARRY LINE when one exists, so from the array
|
|
32
|
+
* alone a consumer cannot tell "the newest completed row" from "the progress bar
|
|
33
|
+
* being redrawn right now" - and that difference is the whole content of
|
|
34
|
+
* `JobProgress.source`. It is also what lets `job_wait` decline to reprint an
|
|
35
|
+
* unchanged, carriage-return-rewritten bar on every single wait (RV-P1-5).
|
|
36
|
+
*/
|
|
37
|
+
carryRow: string;
|
|
27
38
|
}
|
|
28
39
|
/**
|
|
29
40
|
* A per-service ring.
|
|
@@ -51,6 +62,17 @@ export declare class LogRing {
|
|
|
51
62
|
append(chunk: string): readonly string[];
|
|
52
63
|
/** The last `n` display rows, in-progress line included when non-empty. */
|
|
53
64
|
tail(n: number): readonly string[];
|
|
65
|
+
/**
|
|
66
|
+
* The not-yet-terminated line, exactly as the display would show it.
|
|
67
|
+
*
|
|
68
|
+
* THE PROGRESS EXTRACTOR NEEDS THIS SEPARATELY FROM `tail()`, and that is the
|
|
69
|
+
* only reason it is public: `tail()` appends the carry line as its last
|
|
70
|
+
* element, so a consumer that is handed the array cannot tell whether that last
|
|
71
|
+
* element is a completed row or the bar currently being redrawn. A tqdm run and
|
|
72
|
+
* a silent job would look identical, and the difference is precisely the
|
|
73
|
+
* information `JobProgress.source` carries.
|
|
74
|
+
*/
|
|
75
|
+
get carryRow(): string;
|
|
54
76
|
/** Every completed row plus the in-progress line. */
|
|
55
77
|
all(): readonly string[];
|
|
56
78
|
/** Completed rows ever appended — the paging cursor and the revision term. */
|
package/dist/proc/log-ring.js
CHANGED
|
@@ -65,6 +65,19 @@ export class LogRing {
|
|
|
65
65
|
const all = live.length > 0 ? [...this.rows, live] : this.rows;
|
|
66
66
|
return Object.freeze(all.length > n ? all.slice(-n) : [...all]);
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The not-yet-terminated line, exactly as the display would show it.
|
|
70
|
+
*
|
|
71
|
+
* THE PROGRESS EXTRACTOR NEEDS THIS SEPARATELY FROM `tail()`, and that is the
|
|
72
|
+
* only reason it is public: `tail()` appends the carry line as its last
|
|
73
|
+
* element, so a consumer that is handed the array cannot tell whether that last
|
|
74
|
+
* element is a completed row or the bar currently being redrawn. A tqdm run and
|
|
75
|
+
* a silent job would look identical, and the difference is precisely the
|
|
76
|
+
* information `JobProgress.source` carries.
|
|
77
|
+
*/
|
|
78
|
+
get carryRow() {
|
|
79
|
+
return toDisplayRow(this.carry);
|
|
80
|
+
}
|
|
68
81
|
/** Every completed row plus the in-progress line. */
|
|
69
82
|
all() {
|
|
70
83
|
return this.tail(this.capacity + 1);
|
|
@@ -87,7 +100,12 @@ export class LogRing {
|
|
|
87
100
|
page(since) {
|
|
88
101
|
const oldest = this.seen - this.rows.length;
|
|
89
102
|
if (since === undefined || since < 0) {
|
|
90
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
rows: this.all(),
|
|
105
|
+
cursor: this.seen,
|
|
106
|
+
truncated: this.truncated,
|
|
107
|
+
carryRow: toDisplayRow(this.carry),
|
|
108
|
+
};
|
|
91
109
|
}
|
|
92
110
|
const from = Math.max(since, oldest);
|
|
93
111
|
const rows = this.rows.slice(from - oldest);
|
|
@@ -97,6 +115,7 @@ export class LogRing {
|
|
|
97
115
|
rows: Object.freeze(withLive),
|
|
98
116
|
cursor: this.seen,
|
|
99
117
|
truncated: since < oldest,
|
|
118
|
+
carryRow: live,
|
|
100
119
|
};
|
|
101
120
|
}
|
|
102
121
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The completion-notice queue and its one renderer (long-task-jobs §4.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* A NOTICE IS NOT A MESSAGE. It is a bounded, factual sentence about a job that
|
|
7
|
+
* ended while the model was busy, and it reaches the model through three
|
|
8
|
+
* mutually exclusive paths that all take from THIS queue - the tail of the next
|
|
9
|
+
* tool result (pull), a `steer()` at the one safe point (push), and a
|
|
10
|
+
* `steer()` before the next user turn (fill). One `drain()` behind all three is
|
|
11
|
+
* what makes "at most once" a property of the data structure rather than a
|
|
12
|
+
* promise in three call sites (I-6).
|
|
13
|
+
*
|
|
14
|
+
* THE QUEUE IS BOUNDED AND DEDUPLICATED BY ID. A job can only end once, so a
|
|
15
|
+
* second notice for the same id is always the same fact restated - which is what
|
|
16
|
+
* makes the relay's re-queueing after a queue wipe idempotent (I-11).
|
|
17
|
+
*/
|
|
18
|
+
import type { JobKind, ServiceStatus } from './types.js';
|
|
19
|
+
export interface JobNotice {
|
|
20
|
+
/** The job: `t3` / `s1`. */
|
|
21
|
+
id: string;
|
|
22
|
+
kind: JobKind;
|
|
23
|
+
/** Always terminal - a notice is never queued for a running job. */
|
|
24
|
+
status: Extract<ServiceStatus, 'exited' | 'failed' | 'stopped'>;
|
|
25
|
+
exitCode: number | null;
|
|
26
|
+
signal: NodeJS.Signals | null;
|
|
27
|
+
elapsedMs: number;
|
|
28
|
+
/** The last extracted progress line, when there was one. */
|
|
29
|
+
progressText?: string;
|
|
30
|
+
/** At most `PROC_LIMITS.noticeTailRows`, and only for a failure. */
|
|
31
|
+
tailRows: readonly string[];
|
|
32
|
+
at: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Insertion-ordered, deduplicated by id, bounded by `PROC_LIMITS.maxNotices`.
|
|
36
|
+
*
|
|
37
|
+
* INSERTION ORDER IS KEPT because "the oldest thing I have not told you about"
|
|
38
|
+
* is the one that has been waiting longest; the RENDERER then puts failures
|
|
39
|
+
* first, which is a display decision rather than a queue decision.
|
|
40
|
+
*/
|
|
41
|
+
export declare class JobNoticeQueue {
|
|
42
|
+
private readonly entries;
|
|
43
|
+
/** Same id twice keeps ONE entry: a job's terminal fact does not change. */
|
|
44
|
+
push(notice: JobNotice): void;
|
|
45
|
+
/** Take everything and reset. The one way a notice leaves this object. */
|
|
46
|
+
drain(): JobNotice[];
|
|
47
|
+
pending(): number;
|
|
48
|
+
clear(): void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Render the `<job_notices>` block, or `''` when there is nothing to say.
|
|
52
|
+
*
|
|
53
|
+
* EMPTY IN, EMPTY OUT, and the wrapper that appends this to a tool result relies
|
|
54
|
+
* on it: an unconditional block would add two lines to every tool call in every
|
|
55
|
+
* session, which is the context growth this feature exists to avoid.
|
|
56
|
+
*/
|
|
57
|
+
export declare function renderJobNotices(notices: readonly JobNotice[]): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The completion-notice queue and its one renderer (long-task-jobs §4.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* A NOTICE IS NOT A MESSAGE. It is a bounded, factual sentence about a job that
|
|
7
|
+
* ended while the model was busy, and it reaches the model through three
|
|
8
|
+
* mutually exclusive paths that all take from THIS queue - the tail of the next
|
|
9
|
+
* tool result (pull), a `steer()` at the one safe point (push), and a
|
|
10
|
+
* `steer()` before the next user turn (fill). One `drain()` behind all three is
|
|
11
|
+
* what makes "at most once" a property of the data structure rather than a
|
|
12
|
+
* promise in three call sites (I-6).
|
|
13
|
+
*
|
|
14
|
+
* THE QUEUE IS BOUNDED AND DEDUPLICATED BY ID. A job can only end once, so a
|
|
15
|
+
* second notice for the same id is always the same fact restated - which is what
|
|
16
|
+
* makes the relay's re-queueing after a queue wipe idempotent (I-11).
|
|
17
|
+
*/
|
|
18
|
+
import { formatDuration } from '../agent/usage.js';
|
|
19
|
+
import { PROC_LIMITS } from './limits.js';
|
|
20
|
+
/**
|
|
21
|
+
* Insertion-ordered, deduplicated by id, bounded by `PROC_LIMITS.maxNotices`.
|
|
22
|
+
*
|
|
23
|
+
* INSERTION ORDER IS KEPT because "the oldest thing I have not told you about"
|
|
24
|
+
* is the one that has been waiting longest; the RENDERER then puts failures
|
|
25
|
+
* first, which is a display decision rather than a queue decision.
|
|
26
|
+
*/
|
|
27
|
+
export class JobNoticeQueue {
|
|
28
|
+
entries = new Map();
|
|
29
|
+
/** Same id twice keeps ONE entry: a job's terminal fact does not change. */
|
|
30
|
+
push(notice) {
|
|
31
|
+
if (this.entries.has(notice.id))
|
|
32
|
+
return;
|
|
33
|
+
this.entries.set(notice.id, notice);
|
|
34
|
+
// Oldest first out when the bound is exceeded. Dropping the newest instead
|
|
35
|
+
// would bias the block towards facts the model already knows.
|
|
36
|
+
while (this.entries.size > PROC_LIMITS.maxNotices) {
|
|
37
|
+
const oldest = this.entries.keys().next();
|
|
38
|
+
if (oldest.done === true)
|
|
39
|
+
break;
|
|
40
|
+
this.entries.delete(oldest.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Take everything and reset. The one way a notice leaves this object. */
|
|
44
|
+
drain() {
|
|
45
|
+
if (this.entries.size === 0)
|
|
46
|
+
return [];
|
|
47
|
+
const out = [...this.entries.values()];
|
|
48
|
+
this.entries.clear();
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
pending() {
|
|
52
|
+
return this.entries.size;
|
|
53
|
+
}
|
|
54
|
+
clear() {
|
|
55
|
+
this.entries.clear();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** `exited code 0` / `exited signal SIGKILL` / `stopped` - the same grammar as a card. */
|
|
59
|
+
function outcomeOf(notice) {
|
|
60
|
+
if (notice.status === 'stopped')
|
|
61
|
+
return 'stopped';
|
|
62
|
+
if (notice.exitCode === null) {
|
|
63
|
+
return notice.signal === null ? notice.status : `${notice.status} on ${notice.signal}`;
|
|
64
|
+
}
|
|
65
|
+
return `${notice.status} code ${notice.exitCode}`;
|
|
66
|
+
}
|
|
67
|
+
/** Failures first, then non-zero exits, then the rest - in queue order within a group. */
|
|
68
|
+
function rank(notice) {
|
|
69
|
+
if (notice.status === 'failed')
|
|
70
|
+
return 0;
|
|
71
|
+
if (notice.status === 'exited' && notice.exitCode !== 0)
|
|
72
|
+
return 1;
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Render the `<job_notices>` block, or `''` when there is nothing to say.
|
|
77
|
+
*
|
|
78
|
+
* EMPTY IN, EMPTY OUT, and the wrapper that appends this to a tool result relies
|
|
79
|
+
* on it: an unconditional block would add two lines to every tool call in every
|
|
80
|
+
* session, which is the context growth this feature exists to avoid.
|
|
81
|
+
*/
|
|
82
|
+
export function renderJobNotices(notices) {
|
|
83
|
+
if (notices.length === 0)
|
|
84
|
+
return '';
|
|
85
|
+
const sorted = [...notices].sort((a, b) => rank(a) - rank(b));
|
|
86
|
+
const lines = ['<job_notices>'];
|
|
87
|
+
for (const notice of sorted.slice(0, PROC_LIMITS.maxNotices)) {
|
|
88
|
+
const progress = notice.progressText ? ` (${notice.progressText} at last check)` : '';
|
|
89
|
+
lines.push(`${notice.kind} ${notice.id} ${outcomeOf(notice)} after ` +
|
|
90
|
+
`${formatDuration(notice.elapsedMs)}${progress}`);
|
|
91
|
+
// ONLY A FAILURE EARNS A TAIL: a successful job's last rows are noise, and
|
|
92
|
+
// two lines per success is the difference between a bounded block and a log.
|
|
93
|
+
if (rank(notice) < 2) {
|
|
94
|
+
for (const row of notice.tailRows.slice(0, PROC_LIMITS.noticeTailRows)) {
|
|
95
|
+
lines.push(` last: ${row}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lines.push('</job_notices>');
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress extraction for a long-running job (long-task-jobs §4.5).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* PURE: no IO, no clock, no process. That is what lets `progress.test.ts`
|
|
7
|
+
* enumerate the whole table, and it is why the cost of a progress line is one
|
|
8
|
+
* function call inside an already-coalesced output tick rather than a second
|
|
9
|
+
* subscription to the child's stdio.
|
|
10
|
+
*
|
|
11
|
+
* THE POINT IS EXTRACTION, NOT TRANSPORT. The alternative - handing the model
|
|
12
|
+
* the last 200 rows so it can notice the `47%` itself - costs 4k-8k tokens per
|
|
13
|
+
* check and buries the one number that matters. Here the ring keeps the rows and
|
|
14
|
+
* the model gets `47%`.
|
|
15
|
+
*
|
|
16
|
+
* NOTHING DECIDES ANYTHING FROM THIS (R-4). Progress is a REPORT. `status`,
|
|
17
|
+
* `exitCode` and `signal` are the only things a job's outcome is ever read from,
|
|
18
|
+
* so a false `100%` in `[100%] built target foo` costs one confusing word rather
|
|
19
|
+
* than a wrong conclusion.
|
|
20
|
+
*/
|
|
21
|
+
export type ProgressKind = 'percent' | 'ratio' | 'epoch' | 'bytes' | 'text';
|
|
22
|
+
export interface JobProgress {
|
|
23
|
+
/** Already clipped to `PROC_LIMITS.waitProgressChars`. */
|
|
24
|
+
text: string;
|
|
25
|
+
kind: ProgressKind;
|
|
26
|
+
/** `carry` = the not-yet-newline-terminated line, where every progress bar lives. */
|
|
27
|
+
source: 'row' | 'carry';
|
|
28
|
+
/** Index into the scanned window; `-1` for the carry line. Diagnostics and tests only. */
|
|
29
|
+
rowIndex: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extract one line of progress from the tail of a job's output.
|
|
33
|
+
*
|
|
34
|
+
* `rows` is the scan window (the caller passes at most
|
|
35
|
+
* `PROC_LIMITS.progressScanRows`); `carry` is the not-yet-terminated line, which
|
|
36
|
+
* is where a `\r`-rewritten bar keeps its whole life. Returns `undefined` when
|
|
37
|
+
* nothing but decoration has been printed - an honest "there is no progress to
|
|
38
|
+
* show", which the callers render as absence rather than as a fake `0%`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractProgress(rows: readonly string[], carry: string): JobProgress | undefined;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress extraction for a long-running job (long-task-jobs §4.5).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* PURE: no IO, no clock, no process. That is what lets `progress.test.ts`
|
|
7
|
+
* enumerate the whole table, and it is why the cost of a progress line is one
|
|
8
|
+
* function call inside an already-coalesced output tick rather than a second
|
|
9
|
+
* subscription to the child's stdio.
|
|
10
|
+
*
|
|
11
|
+
* THE POINT IS EXTRACTION, NOT TRANSPORT. The alternative - handing the model
|
|
12
|
+
* the last 200 rows so it can notice the `47%` itself - costs 4k-8k tokens per
|
|
13
|
+
* check and buries the one number that matters. Here the ring keeps the rows and
|
|
14
|
+
* the model gets `47%`.
|
|
15
|
+
*
|
|
16
|
+
* NOTHING DECIDES ANYTHING FROM THIS (R-4). Progress is a REPORT. `status`,
|
|
17
|
+
* `exitCode` and `signal` are the only things a job's outcome is ever read from,
|
|
18
|
+
* so a false `100%` in `[100%] built target foo` costs one confusing word rather
|
|
19
|
+
* than a wrong conclusion.
|
|
20
|
+
*/
|
|
21
|
+
import { PROC_LIMITS } from './limits.js';
|
|
22
|
+
/**
|
|
23
|
+
* The ordered table (first match wins PER ROW, newest row scanned first).
|
|
24
|
+
*
|
|
25
|
+
* BYTES SITS ABOVE THE GENERIC RATIO, and that rewrites the order the design
|
|
26
|
+
* document lists. `1.2/3.4 GiB` satisfies the generic ratio pattern as `2/3`
|
|
27
|
+
* (the digits either side of the slash, with `.` a word boundary), so a table
|
|
28
|
+
* that tried `\d+/\d+` first would report every download as `2/3`. The document's
|
|
29
|
+
* constraint - ordered, anchored, single-line, no backtracking blowup - is kept;
|
|
30
|
+
* only the relative order of those two rows changed. Recorded in
|
|
31
|
+
* `docs/plans/long-task-jobs/spec.md` under `## 实施过程发现的方案缺陷` (I-1).
|
|
32
|
+
*/
|
|
33
|
+
const PERCENT = /(\d{1,3}(?:\.\d+)?)\s*%/;
|
|
34
|
+
const EPOCH_STEP = /\b(epoch|step)\s+(\d{1,7})\s*\/\s*(\d{1,7})/i;
|
|
35
|
+
const BYTES = /(\d+(?:\.\d+)?)\s*(GiB|MiB|KiB|GB|MB|KB|gb|mb|kb|gib|mib)?\s*\/\s*(\d+(?:\.\d+)?)\s*(GiB|MiB|KiB|GB|MB|KB|gb|mb|kb|gib|mib)/;
|
|
36
|
+
const RATIO = /[\[(]?\b(\d{1,7})\s*\/\s*(\d{1,7})\b[\])]?/;
|
|
37
|
+
const ETA = /ETA[^0-9]{0,4}([0-9:]+[a-z]*)/i;
|
|
38
|
+
/** A row that is decoration, not information: `...`, `|`, `-`, `===`, a bare path separator. */
|
|
39
|
+
const NOISE = /^[\s.\-_=|\\/+*#>~]+$/;
|
|
40
|
+
/** Clip to the one-line budget, ASCII ellipsis, never mid-surrogate. */
|
|
41
|
+
function clip(text, limit = PROC_LIMITS.waitProgressChars) {
|
|
42
|
+
// VERTICAL whitespace only is collapsed: a stray `\r` or `\n` would break the
|
|
43
|
+
// one-row contract, while the DOUBLE SPACE before `ETA` is how the row is meant
|
|
44
|
+
// to read (`47% ETA 13m`) and collapsing it would make the table's rendering
|
|
45
|
+
// depend on which function ran last.
|
|
46
|
+
const flat = text.replace(/[\r\n\t]+/g, ' ').trim();
|
|
47
|
+
if (flat.length <= limit)
|
|
48
|
+
return flat;
|
|
49
|
+
return `${flat.slice(0, Math.max(0, limit - 3)).trimEnd()}...`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The structured signals for one line, in table order.
|
|
53
|
+
*
|
|
54
|
+
* ORDER WITHIN A LINE IS THE TABLE'S ORDER; the caller decides which LINE wins
|
|
55
|
+
* (newest first), because a live `47%` must beat a stale `100%` from the phase
|
|
56
|
+
* above it.
|
|
57
|
+
*/
|
|
58
|
+
function signalsOf(line) {
|
|
59
|
+
const percent = PERCENT.exec(line);
|
|
60
|
+
if (percent?.[1])
|
|
61
|
+
return { text: `${percent[1]}%`, kind: 'percent' };
|
|
62
|
+
const epoch = EPOCH_STEP.exec(line);
|
|
63
|
+
if (epoch?.[1] && epoch[2] !== undefined && epoch[3] !== undefined) {
|
|
64
|
+
return { text: `${epoch[1].toLowerCase()} ${epoch[2]}/${epoch[3]}`, kind: 'epoch' };
|
|
65
|
+
}
|
|
66
|
+
const bytes = BYTES.exec(line);
|
|
67
|
+
if (bytes?.[1] && bytes[3]) {
|
|
68
|
+
// Normalised to the TOTAL's unit, which is the one the user's own command
|
|
69
|
+
// printed: `1.2/3.4 GiB` reads, `1.2 GiB/3.4 GiB` is noise. The numerator's
|
|
70
|
+
// unit is optional because the common form writes it once, at the end.
|
|
71
|
+
return { text: `${bytes[1]}/${bytes[3]} ${bytes[4]}`, kind: 'bytes' };
|
|
72
|
+
}
|
|
73
|
+
const ratio = RATIO.exec(line);
|
|
74
|
+
if (ratio?.[1] && ratio[2] !== undefined) {
|
|
75
|
+
return { text: `${ratio[1]}/${ratio[2]}`, kind: 'ratio' };
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Append the ETA once, when the window names one. */
|
|
80
|
+
function withEta(signal, window) {
|
|
81
|
+
if (signal.text.includes('ETA'))
|
|
82
|
+
return signal;
|
|
83
|
+
for (const line of window) {
|
|
84
|
+
const eta = ETA.exec(line);
|
|
85
|
+
if (eta?.[1])
|
|
86
|
+
return { text: `${signal.text} ETA ${eta[1]}`, kind: signal.kind };
|
|
87
|
+
}
|
|
88
|
+
return signal;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Extract one line of progress from the tail of a job's output.
|
|
92
|
+
*
|
|
93
|
+
* `rows` is the scan window (the caller passes at most
|
|
94
|
+
* `PROC_LIMITS.progressScanRows`); `carry` is the not-yet-terminated line, which
|
|
95
|
+
* is where a `\r`-rewritten bar keeps its whole life. Returns `undefined` when
|
|
96
|
+
* nothing but decoration has been printed - an honest "there is no progress to
|
|
97
|
+
* show", which the callers render as absence rather than as a fake `0%`.
|
|
98
|
+
*/
|
|
99
|
+
export function extractProgress(rows, carry) {
|
|
100
|
+
const displayCarry = carry.trim();
|
|
101
|
+
// NEWEST FIRST, INSIDE ONE WINDOW. The carry line is the newest of all by
|
|
102
|
+
// definition, so it is tested before every completed row.
|
|
103
|
+
const candidates = [];
|
|
104
|
+
if (displayCarry.length > 0 && !NOISE.test(displayCarry)) {
|
|
105
|
+
candidates.push({ line: displayCarry, source: 'carry', rowIndex: -1 });
|
|
106
|
+
}
|
|
107
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
108
|
+
const line = rows[i] ?? '';
|
|
109
|
+
if (line.trim().length === 0 || NOISE.test(line))
|
|
110
|
+
continue;
|
|
111
|
+
candidates.push({ line, source: 'row', rowIndex: i });
|
|
112
|
+
}
|
|
113
|
+
const window = candidates.map((c) => c.line);
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
const signal = signalsOf(candidate.line);
|
|
116
|
+
if (!signal)
|
|
117
|
+
continue;
|
|
118
|
+
return {
|
|
119
|
+
text: clip(withEta(signal, window).text),
|
|
120
|
+
kind: signal.kind,
|
|
121
|
+
source: candidate.source,
|
|
122
|
+
rowIndex: candidate.rowIndex,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Fallback: the newest line that at least says something.
|
|
126
|
+
const last = candidates[0];
|
|
127
|
+
if (!last)
|
|
128
|
+
return undefined;
|
|
129
|
+
return {
|
|
130
|
+
text: clip(last.line),
|
|
131
|
+
kind: 'text',
|
|
132
|
+
source: last.source,
|
|
133
|
+
rowIndex: last.rowIndex,
|
|
134
|
+
};
|
|
135
|
+
}
|
package/dist/proc/prompt.d.ts
CHANGED
|
@@ -19,6 +19,28 @@
|
|
|
19
19
|
* `BACKGROUND_SERVICES_BLOCK_VERSION` exists so a change to this wording is
|
|
20
20
|
* greppable from a behaviour report.
|
|
21
21
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The `<background_jobs>` system-prompt block (long-task-jobs §12).
|
|
24
|
+
*
|
|
25
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope, and this string
|
|
26
|
+
* also reaches a model running in a `cmd.exe` terminal.
|
|
27
|
+
*
|
|
28
|
+
* SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what preserves I-3: with
|
|
29
|
+
* `jobs.enabled: false` the prompt is BYTE-IDENTICAL to the pre-feature output.
|
|
30
|
+
*
|
|
31
|
+
* EVERY SENTENCE HERE ANSWERS A DECISION THE MODEL ACTUALLY HAS TO MAKE:
|
|
32
|
+
* - "do not poll" is the behaviour this feature exists to replace (six
|
|
33
|
+
* `bash_output` calls per 30 seconds, six LLM turns, six 200-row tails);
|
|
34
|
+
* - "a bounded wait is the only thing that tells you it finished" prevents the
|
|
35
|
+
* opposite failure, a model that starts a job and then guesses;
|
|
36
|
+
* - "`<job_notices>` is the notification, not an error" stops it apologising for
|
|
37
|
+
* a block that is good news;
|
|
38
|
+
* - "`[stalled]` is not a failure" stops it killing a job that is merely quiet.
|
|
39
|
+
*
|
|
40
|
+
* `JOBS_BLOCK_VERSION` exists so a change to this wording is greppable from a
|
|
41
|
+
* behaviour report.
|
|
42
|
+
*/
|
|
43
|
+
import { JOBS_BLOCK_VERSION } from './limits.js';
|
|
44
|
+
export { JOBS_BLOCK_VERSION };
|
|
45
|
+
export declare function buildBackgroundJobsBlock(): string;
|
|
24
46
|
export declare function buildBackgroundServicesBlock(): string;
|