@aragon-agent/cli 0.6.24 → 0.6.26
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 +1924 -1786
- package/README.md +2730 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +49 -14
- package/dist/cli.js +214 -2
- package/dist/commands/branch-command.d.ts +61 -0
- package/dist/commands/branch-command.js +117 -0
- package/dist/commands/builtins.js +62 -40
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +48 -1
- package/dist/commands/session-command.d.ts +54 -0
- package/dist/commands/session-command.js +109 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +155 -0
- package/dist/proc/terminal-plan.js +360 -0
- package/dist/session/branch-invocation.d.ts +83 -0
- package/dist/session/branch-invocation.js +109 -0
- package/dist/session/branch-launch.d.ts +70 -0
- package/dist/session/branch-launch.js +248 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -0
- package/dist/session/cli-commands.d.ts +2 -0
- package/dist/session/cli-commands.js +36 -5
- package/dist/session/identity.d.ts +72 -0
- package/dist/session/identity.js +148 -0
- package/dist/session/limits.d.ts +66 -0
- package/dist/session/limits.js +66 -0
- package/dist/session/persist.d.ts +30 -5
- package/dist/session/startup-session.d.ts +107 -0
- package/dist/session/startup-session.js +315 -0
- package/dist/session/store.d.ts +60 -1
- package/dist/session/store.js +95 -6
- package/dist/todo/types.d.ts +5 -3
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +154 -28
- package/dist/ui/Header.js +8 -16
- package/dist/ui/StatusBar.d.ts +8 -0
- package/dist/ui/StatusBar.js +12 -3
- package/dist/ui/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- package/dist/ui/layout/header-hints.d.ts +68 -0
- package/dist/ui/layout/header-hints.js +138 -0
- package/dist/ui/layout/session-chip.d.ts +52 -0
- package/dist/ui/layout/session-chip.js +64 -0
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +8 -1
- package/dist/ui/overlays/HelpOverlay.js +17 -5
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- package/dist/ui/session-bridge.d.ts +26 -0
- package/dist/ui/session-bridge.js +32 -0
- package/package.json +2 -2
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session checkpoint writer (tui-session-id-resume §3.4 / D-2 / D-3 / D-4 /
|
|
3
|
+
* D-21 / D-22 / D-23).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS MODULE EXISTS AT ALL: a TUI conversation lives in memory
|
|
8
|
+
* (`agent.state.messages`). Another terminal can only continue it if the process
|
|
9
|
+
* has put it on disk, and the only serializer that exists (`saveSession`) is
|
|
10
|
+
* written for explicit exports. So this is the one owner of three properties an
|
|
11
|
+
* export never needed:
|
|
12
|
+
*
|
|
13
|
+
* 1. THE FILE ON DISK IS ALWAYS SENDABLE (D-3 / I-4). A checkpoint taken
|
|
14
|
+
* mid-tool would leave an assistant `tool_call` with no `tool_result`, and
|
|
15
|
+
* the next request of the branch that resumes it is an HTTP 400 - the very
|
|
16
|
+
* first thing `--resume` is for.
|
|
17
|
+
* 2. THE REPLACEMENT IS ATOMIC (D-4). This is the repository's first "a writer
|
|
18
|
+
* and a reader coexist" session file: the parent TUI writes while another
|
|
19
|
+
* terminal reads. A truncated read says "corrupt session", and one false
|
|
20
|
+
* report is enough for a user to stop trusting the feature.
|
|
21
|
+
* 3. NOTHING LIVE SURVIVES INTO THE FILE (D-22 / I-11). A `running` tool card
|
|
22
|
+
* that never settles keeps `ToolCard` spinning and keeps `Transcript`'s
|
|
23
|
+
* MONOTONIC settled boundary from ever moving past it, so every frame
|
|
24
|
+
* re-renders the tail. The settlement happens on the WRITE COPY, not on the
|
|
25
|
+
* load path, because `live-session.test.ts` pins the `/save` decision that
|
|
26
|
+
* a reloaded card keeps its status - and `/save` is an explicit export made
|
|
27
|
+
* with the user watching, while a checkpoint is not.
|
|
28
|
+
*
|
|
29
|
+
* NOTHING HERE MAY INTERRUPT A CONVERSATION (I-8): every filesystem failure is
|
|
30
|
+
* swallowed, logged, and reported as a value.
|
|
31
|
+
*/
|
|
32
|
+
import { renameSync, statSync, unlinkSync } from 'node:fs';
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { validateHistory } from '@aragon-agent/core';
|
|
36
|
+
import { getSessionsDir } from '../config/app-paths.js';
|
|
37
|
+
import { getLogger } from '../logging/logger.js';
|
|
38
|
+
import { SESSION_CHECKPOINT_LIMITS } from './limits.js';
|
|
39
|
+
import { saveSession } from './persist.js';
|
|
40
|
+
import { tryAcquireSessionLock } from './store.js';
|
|
41
|
+
/** The literal text a repaired `tool_result` carries. Read by the MODEL next turn. */
|
|
42
|
+
const INTERRUPTED_TOOL_RESULT = 'interrupted before completion';
|
|
43
|
+
/** The literal `preview` a settled tool card carries. Rendered in the transcript. */
|
|
44
|
+
const INTERRUPTED_TOOL_PREVIEW = 'interrupted (session checkpoint)';
|
|
45
|
+
/**
|
|
46
|
+
* Repair every `tool_call` that never received its `tool_result` (D-3).
|
|
47
|
+
*
|
|
48
|
+
* ON A COPY, AND THAT IS THE POINT (I-2). The live history belongs to the
|
|
49
|
+
* engine; a host that edited it would change what the next request sends. Each
|
|
50
|
+
* dangling call gets an `isError` result carrying one fixed sentence, and the
|
|
51
|
+
* caller re-runs `validateHistory` afterwards: a repair that is still not
|
|
52
|
+
* sendable means the file must not be written.
|
|
53
|
+
*/
|
|
54
|
+
export function closeDanglingToolCalls(messages) {
|
|
55
|
+
const open = new Set();
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
if (message.role === 'assistant') {
|
|
58
|
+
for (const block of message.content) {
|
|
59
|
+
if (block.type === 'tool_call')
|
|
60
|
+
open.add(block.toolCallId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (message.role === 'tool_result') {
|
|
64
|
+
open.delete(message.toolCallId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (open.size === 0)
|
|
68
|
+
return { messages: [...messages], repaired: 0 };
|
|
69
|
+
const patched = [...messages];
|
|
70
|
+
for (const toolCallId of open) {
|
|
71
|
+
patched.push({
|
|
72
|
+
role: 'tool_result',
|
|
73
|
+
toolCallId,
|
|
74
|
+
isError: true,
|
|
75
|
+
content: INTERRUPTED_TOOL_RESULT,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return { messages: patched, repaired: open.size };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Settle every `running` tool card on a COPY (D-22 / I-11).
|
|
82
|
+
*
|
|
83
|
+
* `error` rather than `done`: the command did not finish, and a card that said
|
|
84
|
+
* otherwise would claim a result nobody saw. The live tail is cleared for the
|
|
85
|
+
* same reason `stripLiveToolOutput` clears it - it describes a process that died
|
|
86
|
+
* with the session - and `preview` names WHY, so a resumed card is not a silent
|
|
87
|
+
* lie. `pending` is left alone: `ToolCard` draws no spinner for it and
|
|
88
|
+
* `Transcript.nowSecFor` never re-clocks it, so it settles by itself.
|
|
89
|
+
*/
|
|
90
|
+
export function settleRunningToolCards(entries) {
|
|
91
|
+
let settled = 0;
|
|
92
|
+
const out = entries.map((entry) => {
|
|
93
|
+
if (entry.kind !== 'tool' || entry.status !== 'running')
|
|
94
|
+
return entry;
|
|
95
|
+
settled += 1;
|
|
96
|
+
return {
|
|
97
|
+
...entry,
|
|
98
|
+
status: 'error',
|
|
99
|
+
isError: true,
|
|
100
|
+
preview: INTERRUPTED_TOOL_PREVIEW,
|
|
101
|
+
live: undefined,
|
|
102
|
+
liveSeq: undefined,
|
|
103
|
+
lastOutputAt: undefined,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
return { entries: out, settled };
|
|
107
|
+
}
|
|
108
|
+
/** What may reach the disk: nothing at all, or a history the engine can resend. */
|
|
109
|
+
export function isSendable(messages) {
|
|
110
|
+
if (messages.length === 0)
|
|
111
|
+
return true;
|
|
112
|
+
return validateHistory(messages).ok;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The checkpoint writer.
|
|
116
|
+
*
|
|
117
|
+
* ONE TIMER (I-10). Several `agent_end` events in a row - a todo follow-through
|
|
118
|
+
* restarts the run without the user typing - are merged into one write, and the
|
|
119
|
+
* timer is `unref`ed so a pending write can never hold the process open. Every
|
|
120
|
+
* exit path calls `flushSync`, which is idempotent so the three of them
|
|
121
|
+
* (`waitUntilExit().then()`, the signal hook and `process.on('exit')`) can
|
|
122
|
+
* coexist without writing three times.
|
|
123
|
+
*/
|
|
124
|
+
export class SessionCheckpointWriter {
|
|
125
|
+
deps;
|
|
126
|
+
pathFor;
|
|
127
|
+
lastFingerprint = null;
|
|
128
|
+
lastWriteAt = 0;
|
|
129
|
+
createdAt = null;
|
|
130
|
+
pinned;
|
|
131
|
+
failureStreak = 0;
|
|
132
|
+
pending = null;
|
|
133
|
+
unsubscribe = null;
|
|
134
|
+
exited = false;
|
|
135
|
+
/**
|
|
136
|
+
* Whether the LAST WRITE FILE still says `live: true`.
|
|
137
|
+
*
|
|
138
|
+
* The fingerprint deliberately does not include the reason for the write, so
|
|
139
|
+
* without this the exit flush would short-circuit as "nothing changed" and the
|
|
140
|
+
* file would keep `live: true` forever - which is the flag `sessions prune`
|
|
141
|
+
* reads as "a writer is still here" (T-10's sibling, and the reason
|
|
142
|
+
* `flushSync` must be able to write a file whose CONTENT is unchanged but whose
|
|
143
|
+
* METADATA is not).
|
|
144
|
+
*/
|
|
145
|
+
lastWroteLive = false;
|
|
146
|
+
lastResult = null;
|
|
147
|
+
/**
|
|
148
|
+
* THIS PROCESS'S CLAIM ON THE FILE IT WRITES (S-7).
|
|
149
|
+
*
|
|
150
|
+
* Without it, `meta.live` is a flag nobody can verify: `sessions prune` reads
|
|
151
|
+
* the `.lock` payload to decide whether the writer is still alive, and the TUI
|
|
152
|
+
* never took one - so `prune --origin tui --yes` deleted the checkpoint of a
|
|
153
|
+
* session that was running at that moment. The lock is the same one `exec`
|
|
154
|
+
* uses to keep two writers off one file; a killed process leaves a dead pid
|
|
155
|
+
* behind and the existing staleness probe collects it.
|
|
156
|
+
*/
|
|
157
|
+
lock = null;
|
|
158
|
+
lockedId = null;
|
|
159
|
+
constructor(deps) {
|
|
160
|
+
this.deps = deps;
|
|
161
|
+
this.pinned = deps.startPinned === true;
|
|
162
|
+
this.pathFor = deps.pathFor ?? ((id) => join(getSessionsDir(), `${id}.json`));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Write once now: after mount, so the id on screen is resumable immediately.
|
|
166
|
+
*
|
|
167
|
+
* THE `no-identity` ANSWER IS A TIMING ANSWER, NOT A PERMANENT ONE. `render()`
|
|
168
|
+
* returns before `App` has run its first effects, so the session slot can still
|
|
169
|
+
* be empty when this is called - and "the id on screen is not resumable until
|
|
170
|
+
* the first turn" is exactly the failure D-2 exists to prevent (a zero-turn
|
|
171
|
+
* session must already be a session). One deferred retry through the SAME
|
|
172
|
+
* single timer (I-10) turns that window into the write; if the view was already
|
|
173
|
+
* published, the first call succeeded and no timer is armed at all.
|
|
174
|
+
*/
|
|
175
|
+
start() {
|
|
176
|
+
const result = this.write('running');
|
|
177
|
+
if (!result.ok && result.reason === 'no-identity')
|
|
178
|
+
this.schedule();
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
/** Subscribe to `agent_end`. The writer does not own the controller's lifetime. */
|
|
182
|
+
attach(subscribe) {
|
|
183
|
+
this.unsubscribe?.();
|
|
184
|
+
this.unsubscribe = subscribe(() => this.onAgentEnd());
|
|
185
|
+
}
|
|
186
|
+
/** Write now, optionally pinning (`/session pin`) or forcing past the short circuit. */
|
|
187
|
+
writeNow(opts = {}) {
|
|
188
|
+
if (opts.pin)
|
|
189
|
+
this.pinned = true;
|
|
190
|
+
return this.write('running', opts.force === true);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The exit-path write. Never throws, and after the first call it is a no-op
|
|
194
|
+
* (I-10): all three exit paths run on a normal quit.
|
|
195
|
+
*/
|
|
196
|
+
flushSync(reason) {
|
|
197
|
+
if (this.exited)
|
|
198
|
+
return this.lastResult ?? noIdentity();
|
|
199
|
+
this.exited = true;
|
|
200
|
+
this.clearPending();
|
|
201
|
+
const result = this.write(reason);
|
|
202
|
+
// AFTER THE WRITE, BEFORE ANYONE ELSE LOOKS: the exit flush is the last
|
|
203
|
+
// thing this process does to this file, and a lock left behind would make
|
|
204
|
+
// `sessions prune` skip a session nobody is writing any more.
|
|
205
|
+
this.releaseLock();
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
dispose() {
|
|
209
|
+
this.clearPending();
|
|
210
|
+
this.unsubscribe?.();
|
|
211
|
+
this.unsubscribe = null;
|
|
212
|
+
this.releaseLock();
|
|
213
|
+
}
|
|
214
|
+
// -------------------------------------------------------------------------
|
|
215
|
+
// Scheduling
|
|
216
|
+
// -------------------------------------------------------------------------
|
|
217
|
+
onAgentEnd() {
|
|
218
|
+
if (this.fingerprint() === this.lastFingerprint)
|
|
219
|
+
return;
|
|
220
|
+
this.schedule();
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Coalesce into one timer. `debounceMs` is the window; `minWriteIntervalMs`
|
|
224
|
+
* keeps two consecutive run boundaries - which arrive closer together than a
|
|
225
|
+
* human can type - from landing two full session writes back to back.
|
|
226
|
+
*/
|
|
227
|
+
schedule() {
|
|
228
|
+
if (this.pending)
|
|
229
|
+
return;
|
|
230
|
+
const since = this.now() - this.lastWriteAt;
|
|
231
|
+
const wait = Math.max(SESSION_CHECKPOINT_LIMITS.debounceMs, SESSION_CHECKPOINT_LIMITS.minWriteIntervalMs - since);
|
|
232
|
+
const timer = setTimeout(() => {
|
|
233
|
+
this.pending = null;
|
|
234
|
+
this.write('running');
|
|
235
|
+
}, wait);
|
|
236
|
+
// A checkpoint must never be the reason `aragon` does not exit.
|
|
237
|
+
timer.unref?.();
|
|
238
|
+
this.pending = timer;
|
|
239
|
+
}
|
|
240
|
+
clearPending() {
|
|
241
|
+
if (!this.pending)
|
|
242
|
+
return;
|
|
243
|
+
clearTimeout(this.pending);
|
|
244
|
+
this.pending = null;
|
|
245
|
+
}
|
|
246
|
+
now() {
|
|
247
|
+
return (this.deps.now ?? Date.now)();
|
|
248
|
+
}
|
|
249
|
+
// -------------------------------------------------------------------------
|
|
250
|
+
// Writing
|
|
251
|
+
// -------------------------------------------------------------------------
|
|
252
|
+
/**
|
|
253
|
+
* The full write, in the one order that keeps the file honest: read, repair,
|
|
254
|
+
* gate, settle, write to a temporary file, replace.
|
|
255
|
+
*
|
|
256
|
+
* `force` is ONE switch with ONE effect (D-15 / I-7): it skips the fingerprint
|
|
257
|
+
* short circuit below. Everything after it - the dangling-call repair, the
|
|
258
|
+
* sendable gate, the settle pass, the atomic replace - runs unchanged, because a
|
|
259
|
+
* forced write still has to produce a file `loadSession()` can read back.
|
|
260
|
+
*/
|
|
261
|
+
write(reason, force = false) {
|
|
262
|
+
const view = this.deps.readView();
|
|
263
|
+
if (!view) {
|
|
264
|
+
this.lastResult = noIdentity();
|
|
265
|
+
return this.lastResult;
|
|
266
|
+
}
|
|
267
|
+
const fingerprint = this.fingerprint();
|
|
268
|
+
const wantsLive = reason === 'running';
|
|
269
|
+
if (!force && fingerprint === this.lastFingerprint && wantsLive === this.lastWroteLive) {
|
|
270
|
+
// No change: not touching the file is the answer, not a failure (T-11).
|
|
271
|
+
// `force` skips exactly this branch and nothing else (D-15 / I-7).
|
|
272
|
+
this.lastResult = {
|
|
273
|
+
ok: true,
|
|
274
|
+
path: this.pathFor(view.id),
|
|
275
|
+
bytes: 0,
|
|
276
|
+
repaired: 0,
|
|
277
|
+
settled: 0,
|
|
278
|
+
};
|
|
279
|
+
return this.lastResult;
|
|
280
|
+
}
|
|
281
|
+
const history = this.deps.readHistory();
|
|
282
|
+
const repaired = closeDanglingToolCalls(history.messages);
|
|
283
|
+
if (!isSendable(repaired.messages)) {
|
|
284
|
+
// SKIPPED, NOT WRITTEN (D-3). An orphan `tool_result` cannot be repaired
|
|
285
|
+
// host-side - inventing the `tool_call` it belongs to would forge a call
|
|
286
|
+
// the engine never made - so the previous good file stays, and the reason
|
|
287
|
+
// goes to the log rather than into the user's conversation (I-8).
|
|
288
|
+
const verdict = validateHistory(repaired.messages);
|
|
289
|
+
const why = verdict.ok ? 'unknown' : verdict.reason;
|
|
290
|
+
getLogger().warn('session', 'checkpoint_not_sendable', { reason: why });
|
|
291
|
+
this.lastResult = { ok: false, reason: 'not-sendable', message: why };
|
|
292
|
+
return this.lastResult;
|
|
293
|
+
}
|
|
294
|
+
const settled = settleRunningToolCards(view.entries);
|
|
295
|
+
this.lastResult = this.persist({
|
|
296
|
+
view,
|
|
297
|
+
messages: repaired.messages,
|
|
298
|
+
...(history.compactionIdentity ? { compactionIdentity: history.compactionIdentity } : {}),
|
|
299
|
+
entries: settled.entries,
|
|
300
|
+
repaired: repaired.repaired,
|
|
301
|
+
settled: settled.settled,
|
|
302
|
+
reason,
|
|
303
|
+
});
|
|
304
|
+
if (this.lastResult.ok) {
|
|
305
|
+
this.lastFingerprint = fingerprint;
|
|
306
|
+
this.lastWroteLive = wantsLive;
|
|
307
|
+
this.lastWriteAt = this.now();
|
|
308
|
+
this.failureStreak = 0;
|
|
309
|
+
if (this.createdAt === null)
|
|
310
|
+
this.createdAt = this.lastWriteAt;
|
|
311
|
+
}
|
|
312
|
+
return this.lastResult;
|
|
313
|
+
}
|
|
314
|
+
/** Serialize to `<id>.json.tmp-<uuid>` and rename over the real file (D-4). */
|
|
315
|
+
persist(input) {
|
|
316
|
+
this.holdLock(input.view.id);
|
|
317
|
+
const path = this.pathFor(input.view.id);
|
|
318
|
+
const tmp = `${path}.tmp-${randomUUID()}`;
|
|
319
|
+
const data = {
|
|
320
|
+
model: this.deps.readModel(),
|
|
321
|
+
messages: input.messages,
|
|
322
|
+
...(input.compactionIdentity ? { compactionIdentity: input.compactionIdentity } : {}),
|
|
323
|
+
entries: input.entries,
|
|
324
|
+
todos: this.deps.readTodos(),
|
|
325
|
+
meta: this.buildMeta(input),
|
|
326
|
+
};
|
|
327
|
+
try {
|
|
328
|
+
saveSession(tmp, data);
|
|
329
|
+
const bytes = replaceFile(tmp, path, () => saveSession(path, data));
|
|
330
|
+
if (input.repaired > 0) {
|
|
331
|
+
getLogger().info('session', 'checkpoint_repaired_tool_calls', { repaired: input.repaired });
|
|
332
|
+
}
|
|
333
|
+
if (input.settled > 0) {
|
|
334
|
+
getLogger().info('session', 'checkpoint_settled_tool_cards', { settled: input.settled });
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
path,
|
|
339
|
+
bytes,
|
|
340
|
+
repaired: input.repaired,
|
|
341
|
+
settled: input.settled,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
unlinkQuietly(tmp);
|
|
346
|
+
return this.fail(error);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* `meta`, field by field (§6.1). Two of these are easy to get subtly wrong:
|
|
351
|
+
* `createdAt` is frozen at the first write (re-reading it every time would
|
|
352
|
+
* make the field a second `updatedAt`), and `turns` counts assistant messages
|
|
353
|
+
* IN THE FILE - a quantity a reader can verify, unlike "what this cost".
|
|
354
|
+
*/
|
|
355
|
+
buildMeta(input) {
|
|
356
|
+
const model = this.deps.readModel();
|
|
357
|
+
const parentId = this.deps.readParentId?.();
|
|
358
|
+
return {
|
|
359
|
+
id: input.view.id,
|
|
360
|
+
cwd: this.deps.readCwd(),
|
|
361
|
+
createdAt: this.createdAt ?? this.now(),
|
|
362
|
+
updatedAt: this.now(),
|
|
363
|
+
turns: input.messages.filter((message) => message.role === 'assistant').length,
|
|
364
|
+
cli: this.deps.version,
|
|
365
|
+
provider: model.providerId,
|
|
366
|
+
model: model.modelId,
|
|
367
|
+
usage: {
|
|
368
|
+
inputTokens: input.view.usageTotal.inputTokens,
|
|
369
|
+
outputTokens: input.view.usageTotal.outputTokens,
|
|
370
|
+
},
|
|
371
|
+
origin: 'tui',
|
|
372
|
+
...(this.pinned ? { pinned: true } : {}),
|
|
373
|
+
// Omitted by the exit flush: `live` means "a writer believes a process is
|
|
374
|
+
// still here", and both exit paths know better.
|
|
375
|
+
...(input.reason === 'running' ? { live: true } : {}),
|
|
376
|
+
...(parentId ? { branchOf: parentId } : {}),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Take, or move, this process's lock on the id being written (S-7).
|
|
381
|
+
*
|
|
382
|
+
* BEST EFFORT, AND NEVER FATAL: a checkpoint must not fail because someone
|
|
383
|
+
* else holds the lock - the write is atomic and the file is this id's own, so
|
|
384
|
+
* the worst case is a `live` flag that `prune` declines to trust.
|
|
385
|
+
*/
|
|
386
|
+
holdLock(id) {
|
|
387
|
+
if (this.lockedId === id && this.lock)
|
|
388
|
+
return;
|
|
389
|
+
this.releaseLock();
|
|
390
|
+
this.lockedId = id;
|
|
391
|
+
try {
|
|
392
|
+
this.lock = (this.deps.lockFor ?? tryAcquireSessionLock)(id);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
// An id the lock refuses is an id that could not be a file name either; the
|
|
396
|
+
// write below reports that on its own terms.
|
|
397
|
+
this.lock = null;
|
|
398
|
+
getLogger().debug('session', 'checkpoint_lock_skipped', {
|
|
399
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
releaseLock() {
|
|
404
|
+
this.lock?.release();
|
|
405
|
+
this.lock = null;
|
|
406
|
+
this.lockedId = null;
|
|
407
|
+
}
|
|
408
|
+
/** A failed write is a log line and a return value, never an exception (I-8). */
|
|
409
|
+
fail(error) {
|
|
410
|
+
this.failureStreak += 1;
|
|
411
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
412
|
+
const data = { reason: message, streak: this.failureStreak };
|
|
413
|
+
if (this.failureStreak >= SESSION_CHECKPOINT_LIMITS.loudFailureStreak) {
|
|
414
|
+
getLogger().error('session', 'checkpoint_write_failed', data);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
getLogger().warn('session', 'checkpoint_write_failed', data);
|
|
418
|
+
}
|
|
419
|
+
return { ok: false, reason: 'write-failed', message };
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* A cheap content fingerprint: identity, message count, last message shape,
|
|
423
|
+
* entry count, last entry id, todo count and the pin flag.
|
|
424
|
+
*
|
|
425
|
+
* SHORT AND STRUCTURAL ON PURPOSE. Hashing the whole conversation would cost
|
|
426
|
+
* as much as writing it (K-1), and the question this answers is only "did
|
|
427
|
+
* anything settle since the last write".
|
|
428
|
+
*/
|
|
429
|
+
fingerprint() {
|
|
430
|
+
const view = this.deps.readView();
|
|
431
|
+
if (!view)
|
|
432
|
+
return 'none';
|
|
433
|
+
const { messages } = this.deps.readHistory();
|
|
434
|
+
const last = messages[messages.length - 1];
|
|
435
|
+
const lastShape = last === undefined
|
|
436
|
+
? '-'
|
|
437
|
+
: last.role === 'assistant'
|
|
438
|
+
? `a:${last.stopReason ?? '-'}:${last.content.length}`
|
|
439
|
+
: last.role === 'tool_result'
|
|
440
|
+
? `t:${last.toolCallId}`
|
|
441
|
+
: 'u';
|
|
442
|
+
const lastEntry = view.entries[view.entries.length - 1]?.id ?? '-';
|
|
443
|
+
return [
|
|
444
|
+
view.id,
|
|
445
|
+
messages.length,
|
|
446
|
+
lastShape,
|
|
447
|
+
view.entries.length,
|
|
448
|
+
lastEntry,
|
|
449
|
+
this.deps.readTodos().length,
|
|
450
|
+
this.pinned ? 'pin' : '-',
|
|
451
|
+
].join('|');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function noIdentity() {
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
reason: 'no-identity',
|
|
458
|
+
message: 'This process has no session identity, so nothing was checkpointed.',
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Replace `path` with `tmp`, retrying while Windows refuses (D-4 / K-2).
|
|
463
|
+
*
|
|
464
|
+
* `EPERM`/`EEXIST` here means a reader has the file open, which is the NORMAL
|
|
465
|
+
* case for this feature rather than an error. Three short synchronous waits
|
|
466
|
+
* cover a reader finishing a read; if all of them fail, `degrade()` writes the
|
|
467
|
+
* payload in place and says so in the log - a checkpoint that records its own
|
|
468
|
+
* degradation beats a conversation that was never saved at all.
|
|
469
|
+
*/
|
|
470
|
+
function replaceFile(tmp, path, degrade) {
|
|
471
|
+
let lastError;
|
|
472
|
+
for (let attempt = 0; attempt < SESSION_CHECKPOINT_LIMITS.renameAttempts; attempt += 1) {
|
|
473
|
+
try {
|
|
474
|
+
renameSync(tmp, path);
|
|
475
|
+
return sizeOf(path);
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
lastError = error;
|
|
479
|
+
sleepSync(SESSION_CHECKPOINT_LIMITS.renameRetryMs);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
getLogger().warn('session', 'checkpoint_write_degraded', {
|
|
483
|
+
reason: 'rename-failed',
|
|
484
|
+
error: lastError instanceof Error ? lastError.message : String(lastError),
|
|
485
|
+
});
|
|
486
|
+
degrade();
|
|
487
|
+
unlinkQuietly(tmp);
|
|
488
|
+
return sizeOf(path);
|
|
489
|
+
}
|
|
490
|
+
/** The only synchronous sleep available without a native dependency. */
|
|
491
|
+
function sleepSync(ms) {
|
|
492
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
493
|
+
Atomics.wait(cell, 0, 0, ms);
|
|
494
|
+
}
|
|
495
|
+
function sizeOf(path) {
|
|
496
|
+
try {
|
|
497
|
+
// Measured AFTER the rename, so the number reported describes the file a
|
|
498
|
+
// reader would find rather than the buffer that was written.
|
|
499
|
+
return statSync(path).size;
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function unlinkQuietly(path) {
|
|
506
|
+
try {
|
|
507
|
+
unlinkSync(path);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Already gone, or a handle still open. Neither is worth failing a run for.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive the conversation a `/clear` is about to destroy
|
|
3
|
+
* (clear-command-context-reset §3.8 / D-5 / I-5).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS AT ALL: a command whose whole job is to destroy context is a
|
|
8
|
+
* command a user will eventually fire by accident, one keystroke from `/cwd`. The
|
|
9
|
+
* difference between "I lost an hour of work" and "I resumed it" is one file, and
|
|
10
|
+
* `saveSession()` already knows how to write it.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS NOT `session/store.ts::writeSession()`: that function MOVES THE
|
|
13
|
+
* `--continue` POINTER and writes `meta`. An archive is not a session you continue;
|
|
14
|
+
* it is a record you resume BY PATH. Writing `meta` would also make the file look
|
|
15
|
+
* `aragon exec`-managed to `sessions list` / `prune`, which is the scoping rule
|
|
16
|
+
* `persist.ts` records at the top of `SessionMeta` - so a maintenance command would
|
|
17
|
+
* start deleting the user's cleared conversations.
|
|
18
|
+
*
|
|
19
|
+
* ISOLATION IS TWO DEEP (I-6 / P2-4). The directory is the first fence:
|
|
20
|
+
* `listSessions` / `pruneSessions` / `latestSessionForCwd` read `sessionsDir()`
|
|
21
|
+
* non-recursively and skip anything not ending in `.json` (`store.ts`), so a
|
|
22
|
+
* SUBDIRECTORY name is invisible to all three by construction. Omitting `meta` is
|
|
23
|
+
* the second fence, for the day someone makes that listing recursive or gives
|
|
24
|
+
* `--all` a wider meaning.
|
|
25
|
+
*/
|
|
26
|
+
import type { Message, ModelRef } from '@aragon-agent/core';
|
|
27
|
+
import type { Entry } from '../agent/reducer.js';
|
|
28
|
+
import type { TodoItem } from '../todo/types.js';
|
|
29
|
+
import type { CompactionIdentity } from '../compaction/memory-identity.js';
|
|
30
|
+
/** Everything the archive writes. `entries` is supplied by the command layer. */
|
|
31
|
+
export interface ClearedArchiveInput {
|
|
32
|
+
model: ModelRef;
|
|
33
|
+
messages: Message[];
|
|
34
|
+
compactionIdentity?: CompactionIdentity;
|
|
35
|
+
/** The screen transcript, which lives only in `ViewState` (P0-1). */
|
|
36
|
+
entries: Entry[];
|
|
37
|
+
todos: TodoItem[];
|
|
38
|
+
}
|
|
39
|
+
/** Bounded retention: the newest N cleared conversations survive. */
|
|
40
|
+
export declare const CLEAR_ARCHIVE_LIMITS: {
|
|
41
|
+
readonly maxArchives: 20;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Best-effort archive of the conversation being cleared.
|
|
45
|
+
*
|
|
46
|
+
* @returns the absolute path of the written file, or `null` when there was
|
|
47
|
+
* nothing to save (an empty conversation) or the write failed. NEVER THROWS:
|
|
48
|
+
* a full disk must not be able to stop a user from starting a new conversation
|
|
49
|
+
* (I-5), and a caller that had to wrap this in `try` would eventually forget.
|
|
50
|
+
*/
|
|
51
|
+
export declare function archiveClearedConversation(input: ClearedArchiveInput): string | null;
|