@wjm200509/dsh-memory 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -2
- package/lib/client.js +374 -0
- package/lib/command.js +50 -0
- package/lib/dream.js +386 -0
- package/lib/fsutil.js +24 -0
- package/lib/health.js +66 -0
- package/lib/index.js +147 -0
- package/lib/inject.js +89 -0
- package/lib/paths.js +119 -0
- package/lib/prompt.js +133 -0
- package/lib/runner.js +583 -0
- package/lib/selection.js +15 -0
- package/lib/state.js +242 -0
- package/lib/status.js +55 -0
- package/lib/store.js +320 -0
- package/lib/tools.js +226 -0
- package/lib/trigger.js +97 -0
- package/package.json +59 -4
- package/patch.yml +9 -0
package/lib/runner.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel dreaming runner.
|
|
3
|
+
*
|
|
4
|
+
* Runs for different projects proceed in parallel, up to `concurrency`
|
|
5
|
+
* (a volatile setting); one project never has two runs at once. Runs that
|
|
6
|
+
* write the same user-partition note at once are kept apart by the store's
|
|
7
|
+
* versions: the later write conflicts and is redone on the new content.
|
|
8
|
+
* Policy:
|
|
9
|
+
* - auto: after a session ends, unless paused or `autoDream` is off. Each auto
|
|
10
|
+
* run consolidates ONE project; a freed slot goes first to a waiting manual
|
|
11
|
+
* request, then to the project whose queued session ended first;
|
|
12
|
+
* - failures are sorted by cause. An authentication or quota error pauses auto
|
|
13
|
+
* dreaming at once; a transient one (rate limit, network, 5xx) backs off and
|
|
14
|
+
* retries, and only a streak of them pauses. A batch that keeps failing for
|
|
15
|
+
* its own reasons (blocked, cut off) is set aside after a few attempts
|
|
16
|
+
* instead of blocking the queue. While paused, ended sessions keep queueing;
|
|
17
|
+
* runs already in flight finish;
|
|
18
|
+
* - manual (`/dream`, settings button): runs even while paused; after a
|
|
19
|
+
* successful manual run that started paused, it drains the whole queue and
|
|
20
|
+
* then resumes auto. A manual failure stays paused.
|
|
21
|
+
* - cross-project: the one run that sees every partition, so the one that
|
|
22
|
+
* moves notes between them. It runs alone (it waits for the slots to empty
|
|
23
|
+
* and holds new project runs meanwhile): automatically once a day after
|
|
24
|
+
* project runs happened (and only when there is something to compare), and
|
|
25
|
+
* after the settings page's manual run.
|
|
26
|
+
* A batch leaves the queue only after its run succeeded; a run cut off by its
|
|
27
|
+
* step budget advances each session only as far as the agent read it.
|
|
28
|
+
*/
|
|
29
|
+
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { promisify } from "node:util";
|
|
32
|
+
import zlib from "node:zlib";
|
|
33
|
+
import { isEligible, accumulate, emptyActivity, isSubstantive } from "./trigger.js";
|
|
34
|
+
import { sameProject } from "./paths.js";
|
|
35
|
+
import { DREAM_PROMPT_MARKERS } from "./dream.js";
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 86_400_000;
|
|
38
|
+
const STATUS_LOG_SIZE = 20;
|
|
39
|
+
/** Slot marker of the cross-project run. */
|
|
40
|
+
const CROSS = Symbol("cross-project");
|
|
41
|
+
/** Least time between two automatic cross-project runs. */
|
|
42
|
+
export const CROSS_INTERVAL_MS = DAY_MS;
|
|
43
|
+
/** Waits after the 1st, 2nd, ... consecutive transient failure. */
|
|
44
|
+
export const BACKOFF_MS = [60_000, 180_000];
|
|
45
|
+
/** Consecutive failures that pause auto dreaming. */
|
|
46
|
+
export const MAX_STREAK = BACKOFF_MS.length + 1;
|
|
47
|
+
/** Failed (or no-progress) runs after which a session is set aside. */
|
|
48
|
+
export const POISON_LIMIT = 3;
|
|
49
|
+
/** How long a project whose directory cannot be reached is left alone. */
|
|
50
|
+
export const SNOOZE_MS = 600_000;
|
|
51
|
+
/** Dreaming sessions are small; anything bigger on disk is not one and is not worth reading. */
|
|
52
|
+
const MAX_DREAM_SESSION_BYTES = 8 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sort a failed run's error message by what to do about it.
|
|
56
|
+
* - "fatal": retrying cannot help until the user acts (bad key, no quota, no access);
|
|
57
|
+
* - "transient": likely to pass by itself (rate limit, overload, network, timeout, 5xx);
|
|
58
|
+
* - "other": the batch itself is the likely cause (blocked, cut off, rejected input).
|
|
59
|
+
* @param {string | undefined} message
|
|
60
|
+
* @returns {"fatal" | "transient" | "other"}
|
|
61
|
+
*/
|
|
62
|
+
export function classifyError(message) {
|
|
63
|
+
const text = String(message ?? "");
|
|
64
|
+
if (/\b(401|402|403)\b|unauthori[sz]ed|forbidden|invalid[ _-]?(api[ _-]?)?key|authenticat|insufficient|quota|billing|credit/i.test(text)) return "fatal";
|
|
65
|
+
if (/(?:http|status|code|error)\s*:?\s*(?:408|425|429|5\d\d)\b|\b(?:408|425|429|5\d\d)\s+(?:too many|request timeout|internal|bad gateway|service|gateway)|rate[ _-]?limit|too many requests|overload|timed? ?out|timeout|ETIMEDOUT|ECONN\w*|ENOTFOUND|EAI_AGAIN|EPIPE|socket|network|fetch failed|temporar|unavailable|capacity/i.test(text)) return "transient";
|
|
66
|
+
return "other";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {import("./state.js").LogEntry} LogEntry
|
|
71
|
+
* @typedef {{ ok: boolean, runs: LogEntry[] }} RunReport
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
export class DreamRunner {
|
|
75
|
+
/**
|
|
76
|
+
* @param {{ state: import("./state.js").DreamState,
|
|
77
|
+
* runDream: (job: { cwd?: string, sessionIds?: string[], fromSeqs?: Record<string, number>, projects?: object[], selection?: object, indexMaxBytes: number, signal?: AbortSignal }) => Promise<object>,
|
|
78
|
+
* resolveSelection: () => object | undefined, config: object, now?: () => number,
|
|
79
|
+
* listProjects?: () => Promise<Array<{ cwd: string, notes: number }>>,
|
|
80
|
+
* countUserNotes?: () => Promise<number>,
|
|
81
|
+
* onStatus?: (status: object) => void, afterRun?: () => Promise<void> }} deps
|
|
82
|
+
*/
|
|
83
|
+
constructor({ state, runDream, resolveSelection, config, now = Date.now, listProjects = async () => [], countUserNotes = async () => 1, onStatus, afterRun }) {
|
|
84
|
+
Object.assign(this, { state, runDream, resolveSelection, config, now, listProjects, countUserNotes, onStatus, afterRun });
|
|
85
|
+
/** Projects with a run in flight, in start order. */
|
|
86
|
+
this.active = [];
|
|
87
|
+
/** Manual requests waiting for their project or a free slot. */
|
|
88
|
+
this.waiters = [];
|
|
89
|
+
this.tasks = new Set();
|
|
90
|
+
this.stopped = false;
|
|
91
|
+
/** Abort controllers of the runs in flight. */
|
|
92
|
+
this.aborts = new Set();
|
|
93
|
+
/** Consecutive failed runs (reset by a success). */
|
|
94
|
+
this.streak = 0;
|
|
95
|
+
/** When the last counted failure happened: runs that started before it belong to the same wave. */
|
|
96
|
+
this.waveEnd = -1;
|
|
97
|
+
/** Auto runs wait until this time after a transient failure. */
|
|
98
|
+
this.backoffUntil = 0;
|
|
99
|
+
/** Projects left alone until a time: `{ cwd, until }`. */
|
|
100
|
+
this.snoozed = [];
|
|
101
|
+
/** Pending wake-ups from `pumpLater`. */
|
|
102
|
+
this.pumpTimers = new Set();
|
|
103
|
+
/** What the last manual run found, for the settings page: `{ at, text }`. */
|
|
104
|
+
this.notice = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Called when the plugin unloads (including app shutdown, which also
|
|
109
|
+
* disposes sessions and so fires session endings). Endings still queue;
|
|
110
|
+
* no new run starts, runs in flight are cancelled, and a run that fails
|
|
111
|
+
* because services are going away is neither logged nor allowed to pause
|
|
112
|
+
* auto dreaming.
|
|
113
|
+
*/
|
|
114
|
+
stop() {
|
|
115
|
+
this.stopped = true;
|
|
116
|
+
for (const timer of this.pumpTimers) clearTimeout(timer);
|
|
117
|
+
this.pumpTimers.clear();
|
|
118
|
+
this.cancelAll("dsh-memory is stopping");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Cancel every run in flight (plugin stopping or turned off); their batches stay queued. */
|
|
122
|
+
cancelAll(reason) {
|
|
123
|
+
for (const controller of this.aborts) controller.abort(new Error(reason));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Snapshot for the settings page; `running` is null or the projects in flight. */
|
|
127
|
+
status() {
|
|
128
|
+
return {
|
|
129
|
+
paused: this.state.paused,
|
|
130
|
+
running: this.active.length === 0 ? null : this.active.map((cwd) => (cwd === CROSS ? { scope: "cross" } : { cwd })),
|
|
131
|
+
pendingCount: this.state.pending().length,
|
|
132
|
+
log: this.state.recentLog(STATUS_LOG_SIZE),
|
|
133
|
+
...(this.backoffUntil > this.now() ? { retryAt: this.backoffUntil } : {}),
|
|
134
|
+
...(this.notice ? { notice: this.notice } : {})
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
emit() {
|
|
139
|
+
this.onStatus?.(this.status());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Keep a task visible to `idle()` until it settles. */
|
|
143
|
+
track(promise) {
|
|
144
|
+
this.tasks.add(promise);
|
|
145
|
+
promise.then(() => this.tasks.delete(promise), () => this.tasks.delete(promise));
|
|
146
|
+
return promise;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolves once every run, including runs started meanwhile, has settled. */
|
|
150
|
+
async idle() {
|
|
151
|
+
while (this.tasks.size > 0) await Promise.allSettled([...this.tasks]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Auto path: queue an ended session and start consolidating if allowed. */
|
|
155
|
+
async onSessionEnded(info) {
|
|
156
|
+
if (!this.config.enabled.get()) return;
|
|
157
|
+
if (info.seq <= this.state.watermark(info.sessionId)) return;
|
|
158
|
+
await this.state.enqueue(info);
|
|
159
|
+
this.notice = null;
|
|
160
|
+
this.emit();
|
|
161
|
+
this.pump();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether automatic dreaming may start a run right now. */
|
|
165
|
+
autoAllowed() {
|
|
166
|
+
return !this.stopped && this.config.enabled.get() && this.config.autoDream.get() && this.state.paused === null && this.now() >= this.backoffUntil;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Call `pump()` once `ms` have passed (backoff or snooze ended). */
|
|
170
|
+
pumpLater(ms) {
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
this.pumpTimers.delete(timer);
|
|
173
|
+
this.emit();
|
|
174
|
+
this.pump();
|
|
175
|
+
}, ms);
|
|
176
|
+
timer.unref?.();
|
|
177
|
+
this.pumpTimers.add(timer);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether a run for `cwd` may start now: a free slot and no run in flight
|
|
182
|
+
* for that project; the cross-project run needs every slot empty and
|
|
183
|
+
* blocks every other run.
|
|
184
|
+
*/
|
|
185
|
+
canStart(cwd) {
|
|
186
|
+
if (cwd === CROSS) return this.active.length === 0;
|
|
187
|
+
return this.active.length < this.config.concurrency.get() && !this.active.some((running) => running === CROSS || sameProject(running, cwd));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** An automatic cross-project run is due: projects were consolidated since the last one, and a day has passed. */
|
|
191
|
+
crossDue() {
|
|
192
|
+
return this.state.crossProjects().length > 0 && this.now() - (this.state.crossAt ?? 0) >= CROSS_INTERVAL_MS;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Queued sessions with events past their watermark, oldest ending first; projects that were snoozed are left out. */
|
|
196
|
+
freshJobs(excludeSessionId) {
|
|
197
|
+
const now = this.now();
|
|
198
|
+
this.snoozed = this.snoozed.filter((entry) => entry.until > now);
|
|
199
|
+
return this.state.pending().filter((job) => job.sessionId !== excludeSessionId && job.seq > this.state.watermark(job.sessionId) && !this.snoozed.some((entry) => sameProject(entry.cwd, job.cwd)));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Leave a project alone for a while (its directory cannot be reached) and look again afterwards. */
|
|
203
|
+
snooze(cwd) {
|
|
204
|
+
this.snoozed.push({ cwd, until: this.now() + SNOOZE_MS });
|
|
205
|
+
this.pumpLater(SNOOZE_MS);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Start auto runs for waiting projects while slots are free; a due cross-project run goes first. */
|
|
209
|
+
pump() {
|
|
210
|
+
if (this.autoAllowed() && this.crossDue()) {
|
|
211
|
+
if (this.active.length === 0 && this.waiters.length === 0) {
|
|
212
|
+
this.active.push(CROSS);
|
|
213
|
+
this.track(this.runCross("auto", []));
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
while (this.autoAllowed()) {
|
|
218
|
+
const job = this.freshJobs().find((candidate) => this.canStart(candidate.cwd) && !this.waiters.some((waiter) => sameProject(waiter.cwd, candidate.cwd)));
|
|
219
|
+
if (job === undefined) return;
|
|
220
|
+
this.active.push(job.cwd);
|
|
221
|
+
this.track(this.runGroup("auto", job.cwd, {}, []));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Wait for a slot for `cwd` and hold it; the run releases it. */
|
|
226
|
+
acquire(cwd) {
|
|
227
|
+
if (this.waiters.length === 0 && this.canStart(cwd)) {
|
|
228
|
+
this.active.push(cwd);
|
|
229
|
+
return Promise.resolve();
|
|
230
|
+
}
|
|
231
|
+
return new Promise((resolve) => this.waiters.push({ cwd, resolve }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Free a slot: waiting manual requests first, then auto. */
|
|
235
|
+
release(cwd) {
|
|
236
|
+
const index = this.active.indexOf(cwd);
|
|
237
|
+
if (index !== -1) this.active.splice(index, 1);
|
|
238
|
+
for (const waiter of [...this.waiters]) {
|
|
239
|
+
if (!this.canStart(waiter.cwd)) continue;
|
|
240
|
+
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
|
241
|
+
this.active.push(waiter.cwd);
|
|
242
|
+
waiter.resolve();
|
|
243
|
+
}
|
|
244
|
+
this.emit();
|
|
245
|
+
this.pump();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Manual path.
|
|
250
|
+
* @param {{ cwd?: string, excludeSessionId?: string, cross?: boolean }} scope - project to consolidate first
|
|
251
|
+
* (all when omitted), a session to leave out, and whether to finish with a cross-project run.
|
|
252
|
+
* @returns {Promise<RunReport>}
|
|
253
|
+
*/
|
|
254
|
+
runManual({ cwd, excludeSessionId, cross = false } = {}) {
|
|
255
|
+
return this.track((async () => {
|
|
256
|
+
const runs = [];
|
|
257
|
+
this.notice = null;
|
|
258
|
+
const report = await this.manual({ cwd, excludeSessionId, cross }, runs);
|
|
259
|
+
if (report.ok && runs.length === 0 && !this.stopped) {
|
|
260
|
+
this.notice = { at: this.now(), text: "没有需要整理的会话。" };
|
|
261
|
+
this.emit();
|
|
262
|
+
}
|
|
263
|
+
return report;
|
|
264
|
+
})());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async manual({ cwd, excludeSessionId, cross }, runs) {
|
|
268
|
+
if (!(await this.drain("manual", { cwd, excludeSessionId }, runs)) || this.stopped) return { ok: false, runs };
|
|
269
|
+
const wasPaused = this.state.paused !== null;
|
|
270
|
+
if (wasPaused && !(await this.drain("manual", { excludeSessionId }, runs))) return { ok: false, runs };
|
|
271
|
+
if (cross && !this.stopped) {
|
|
272
|
+
await this.acquire(CROSS);
|
|
273
|
+
if ((await this.runCross("manual", runs)) === "failed") return { ok: false, runs };
|
|
274
|
+
}
|
|
275
|
+
if (this.stopped) return { ok: false, runs };
|
|
276
|
+
if (wasPaused) {
|
|
277
|
+
this.streak = 0;
|
|
278
|
+
this.backoffUntil = 0;
|
|
279
|
+
await this.state.setPaused(null);
|
|
280
|
+
this.emit();
|
|
281
|
+
this.pump();
|
|
282
|
+
}
|
|
283
|
+
return { ok: true, runs };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Run every project in scope, in parallel, until none has sessions left;
|
|
288
|
+
* false once any run failed (no further batch starts after that).
|
|
289
|
+
*/
|
|
290
|
+
async drain(trigger, { cwd, excludeSessionId }, runs) {
|
|
291
|
+
const outcome = { failed: false };
|
|
292
|
+
while (!outcome.failed && !this.stopped) {
|
|
293
|
+
const projects = [];
|
|
294
|
+
for (const job of this.freshJobs(excludeSessionId)) {
|
|
295
|
+
if (cwd !== undefined && !sameProject(job.cwd, cwd)) continue;
|
|
296
|
+
if (!projects.some((project) => sameProject(project, job.cwd))) projects.push(job.cwd);
|
|
297
|
+
}
|
|
298
|
+
if (projects.length === 0) break;
|
|
299
|
+
await Promise.all(projects.map((project) => this.drainProject(trigger, project, excludeSessionId, runs, outcome)));
|
|
300
|
+
}
|
|
301
|
+
return !outcome.failed;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Run one project's batches one after another until it has none left. */
|
|
305
|
+
async drainProject(trigger, cwd, excludeSessionId, runs, outcome) {
|
|
306
|
+
while (!outcome.failed && !this.stopped) {
|
|
307
|
+
await this.acquire(cwd);
|
|
308
|
+
const result = await this.runGroup(trigger, cwd, { excludeSessionId }, runs);
|
|
309
|
+
if (result === "empty") return;
|
|
310
|
+
if (result === "failed") outcome.failed = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Consolidate one project's queued sessions. The caller holds the
|
|
316
|
+
* project's slot; this releases it.
|
|
317
|
+
* @returns {Promise<"empty" | "ran" | "failed">}
|
|
318
|
+
*/
|
|
319
|
+
async runGroup(trigger, groupCwd, { excludeSessionId }, runs) {
|
|
320
|
+
try {
|
|
321
|
+
const queued = this.state.pending().filter((job) => job.sessionId !== excludeSessionId);
|
|
322
|
+
const stale = queued.filter((job) => job.seq <= this.state.watermark(job.sessionId));
|
|
323
|
+
if (stale.length > 0) await this.state.complete(stale);
|
|
324
|
+
/* Capture the seqs this run covers: a session that ends again meanwhile is re-queued with a higher seq and must stay queued. */
|
|
325
|
+
let batch = this.freshJobs(excludeSessionId).filter((job) => sameProject(job.cwd, groupCwd)).map(({ sessionId, seq }) => ({ sessionId, seq }));
|
|
326
|
+
/* A session that already failed twice runs alone, so one bad session cannot drag the others in when it is finally set aside. */
|
|
327
|
+
const suspect = batch.find((job) => this.state.attempts(job.sessionId) >= 2);
|
|
328
|
+
if (suspect !== undefined) batch = [suspect];
|
|
329
|
+
if (batch.length === 0 || this.stopped) return "empty";
|
|
330
|
+
const sessionIds = batch.map((job) => job.sessionId);
|
|
331
|
+
/* Where an earlier, cut-off run left each session: this run starts there. */
|
|
332
|
+
const fromSeqs = Object.fromEntries(sessionIds.map((id) => [id, this.state.watermark(id)]));
|
|
333
|
+
this.emit();
|
|
334
|
+
const result = await this.invoke({ cwd: groupCwd, sessionIds, fromSeqs });
|
|
335
|
+
if (result.skipped) {
|
|
336
|
+
/* Project directory unreachable (unmounted drive): leave the batch queued and look again later. */
|
|
337
|
+
this.snooze(groupCwd);
|
|
338
|
+
return "empty";
|
|
339
|
+
}
|
|
340
|
+
if (!result.ok && (this.stopped || result.cancelled)) return "empty";
|
|
341
|
+
const ok = await this.finish({ trigger, cwd: groupCwd, sessionIds }, result, runs);
|
|
342
|
+
if (ok) {
|
|
343
|
+
await this.completeBatch(trigger, groupCwd, batch, fromSeqs, result, runs);
|
|
344
|
+
await this.state.noteProjectRun(groupCwd);
|
|
345
|
+
} else await this.handleFailure(trigger, groupCwd, batch, result, runs);
|
|
346
|
+
await this.afterRun?.().catch(() => {});
|
|
347
|
+
return ok ? "ran" : "failed";
|
|
348
|
+
} finally {
|
|
349
|
+
this.release(groupCwd);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Take a successful run's batch off the queue. A session the run did not
|
|
355
|
+
* read to the end only advances to where it got to and stays queued; one
|
|
356
|
+
* that makes no progress run after run is set aside.
|
|
357
|
+
*/
|
|
358
|
+
async completeBatch(trigger, cwd, batch, fromSeqs, result, runs) {
|
|
359
|
+
const unfinished = result.unfinished ?? {};
|
|
360
|
+
const covered = batch.map((job) => ({ sessionId: job.sessionId, seq: job.sessionId in unfinished ? unfinished[job.sessionId] : job.seq }));
|
|
361
|
+
await this.state.complete(covered);
|
|
362
|
+
const progressed = batch.filter((job) => job.sessionId in unfinished && unfinished[job.sessionId] > fromSeqs[job.sessionId]).map((job) => job.sessionId);
|
|
363
|
+
await this.state.clearAttempts(progressed);
|
|
364
|
+
const stalled = batch.filter((job) => job.sessionId in unfinished && unfinished[job.sessionId] <= fromSeqs[job.sessionId]).map((job) => job.sessionId);
|
|
365
|
+
if (stalled.length > 0) {
|
|
366
|
+
const counts = await this.state.noteFailure(stalled);
|
|
367
|
+
const giveUp = batch.filter((job) => counts[job.sessionId] >= POISON_LIMIT);
|
|
368
|
+
if (giveUp.length > 0) await this.setAside(trigger, cwd, giveUp, `read no further in ${POISON_LIMIT} runs (step budget)`, runs);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Dequeue sessions that keep failing, and say so in the log. */
|
|
373
|
+
async setAside(trigger, cwd, jobs, why, runs) {
|
|
374
|
+
await this.state.complete(jobs);
|
|
375
|
+
const entry = { at: this.now(), trigger, cwd, sessionIds: jobs.map((job) => job.sessionId), ok: false, error: `skipped after ${POISON_LIMIT} attempts: ${why}`, skipped: true };
|
|
376
|
+
await this.state.appendLog(entry);
|
|
377
|
+
runs.push(entry);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* What a failed run means for the queue and for auto dreaming (see the
|
|
382
|
+
* header): set aside a batch that keeps failing for its own reasons; pause
|
|
383
|
+
* on an auth/quota error, on any manual failure, or on a streak; otherwise
|
|
384
|
+
* back off and retry.
|
|
385
|
+
*/
|
|
386
|
+
async handleFailure(trigger, cwd, batch, result, runs) {
|
|
387
|
+
const kind = classifyError(result.error);
|
|
388
|
+
if (cwd !== undefined && batch.length > 0 && kind === "other") {
|
|
389
|
+
const counts = await this.state.noteFailure(batch.map((job) => job.sessionId));
|
|
390
|
+
const giveUp = batch.filter((job) => counts[job.sessionId] >= POISON_LIMIT);
|
|
391
|
+
if (giveUp.length > 0) {
|
|
392
|
+
await this.setAside(trigger, cwd, giveUp, result.error, runs);
|
|
393
|
+
this.streak = 0;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const since = this.now();
|
|
398
|
+
if (trigger === "manual" || kind === "fatal") {
|
|
399
|
+
await this.state.setPaused({ since, reason: result.error, kind: kind === "fatal" ? "auth" : "manual" });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
/* Failures of runs that started together (several projects in parallel) are one event, not several. */
|
|
403
|
+
const newWave = (result.startedAt ?? since) > this.waveEnd;
|
|
404
|
+
if (newWave) {
|
|
405
|
+
this.streak++;
|
|
406
|
+
this.waveEnd = since;
|
|
407
|
+
}
|
|
408
|
+
if (this.streak >= MAX_STREAK) {
|
|
409
|
+
await this.state.setPaused({ since, reason: `连续失败 ${this.streak} 次:${result.error}`, kind: "repeated" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!newWave) return;
|
|
413
|
+
const wait = BACKOFF_MS[this.streak - 1];
|
|
414
|
+
this.backoffUntil = since + wait;
|
|
415
|
+
this.pumpLater(wait);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The cross-project run. The caller holds the cross slot; this releases it.
|
|
420
|
+
* @returns {Promise<"empty" | "ran" | "failed">}
|
|
421
|
+
*/
|
|
422
|
+
async runCross(trigger, runs) {
|
|
423
|
+
try {
|
|
424
|
+
if (this.stopped) return "empty";
|
|
425
|
+
const projects = await this.listProjects();
|
|
426
|
+
/* Nothing to compare: fewer than two projects have notes and the user partition is empty. An automatic run counts as done, or the due check would start it again at once. */
|
|
427
|
+
const nothingToDo = projects.length === 0 || (trigger === "auto" && projects.filter((project) => project.notes > 0).length < 2 && (await this.countUserNotes()) === 0);
|
|
428
|
+
if (nothingToDo) {
|
|
429
|
+
await this.state.completeCross(this.now());
|
|
430
|
+
return "empty";
|
|
431
|
+
}
|
|
432
|
+
this.emit();
|
|
433
|
+
const result = await this.invoke({ projects });
|
|
434
|
+
if (!result.ok && (this.stopped || result.cancelled)) return "empty";
|
|
435
|
+
const ok = await this.finish({ trigger, scope: "cross", sessionIds: [] }, result, runs);
|
|
436
|
+
if (ok) await this.state.completeCross(this.now());
|
|
437
|
+
else await this.handleFailure(trigger, undefined, [], result, runs);
|
|
438
|
+
await this.afterRun?.().catch(() => {});
|
|
439
|
+
return ok ? "ran" : "failed";
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (this.stopped) return "empty";
|
|
442
|
+
const result = { ok: false, error: error?.message ?? String(error) };
|
|
443
|
+
await this.finish({ trigger, scope: "cross", sessionIds: [] }, result, runs);
|
|
444
|
+
await this.handleFailure(trigger, undefined, [], result, runs);
|
|
445
|
+
return "failed";
|
|
446
|
+
} finally {
|
|
447
|
+
this.release(CROSS);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Call `runDream`, turning a throw into a failed result. A run cancelled by `stop()` or `cancelAll()` comes back with `cancelled`. */
|
|
452
|
+
async invoke(job) {
|
|
453
|
+
const controller = new AbortController();
|
|
454
|
+
const startedAt = this.now();
|
|
455
|
+
this.aborts.add(controller);
|
|
456
|
+
try {
|
|
457
|
+
const result = await this.runDream({ ...job, selection: this.resolveSelection(), indexMaxBytes: this.config.indexMaxBytes.get(), signal: controller.signal });
|
|
458
|
+
return !result.ok && controller.signal.aborted ? { ...result, startedAt, cancelled: true } : { ...result, startedAt };
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return { ok: false, startedAt, error: error?.message ?? String(error), ...(controller.signal.aborted ? { cancelled: true } : {}) };
|
|
461
|
+
} finally {
|
|
462
|
+
this.aborts.delete(controller);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Log one run. @returns {Promise<boolean>} whether it succeeded. */
|
|
467
|
+
async finish(base, result, runs) {
|
|
468
|
+
const entry = {
|
|
469
|
+
at: this.now(),
|
|
470
|
+
...base,
|
|
471
|
+
ok: result.ok,
|
|
472
|
+
...(result.ok ? { changes: result.changes, issues: result.issues ?? [] } : { error: result.error }),
|
|
473
|
+
...(result.ok && result.summary ? { summary: result.summary } : {}),
|
|
474
|
+
...(result.dreamSessionId ? { dreamSessionId: result.dreamSessionId } : {})
|
|
475
|
+
};
|
|
476
|
+
await this.state.appendLog(entry);
|
|
477
|
+
runs.push(entry);
|
|
478
|
+
if (result.ok) {
|
|
479
|
+
this.streak = 0;
|
|
480
|
+
this.waveEnd = -1;
|
|
481
|
+
}
|
|
482
|
+
return result.ok;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Queue sessions that ended without being noticed (host restart, idle timer
|
|
488
|
+
* lost): recent, cold, eligible sessions whose events past their watermark
|
|
489
|
+
* are worth a run (`isSubstantive`).
|
|
490
|
+
* @param sessionQuery - `ctx.sessionQuery`.
|
|
491
|
+
* @param {import("./state.js").DreamState} state
|
|
492
|
+
* @param {{ now?: () => number, cwd?: string, excludeSessionId?: string, maxAgeMs?: number }} [options]
|
|
493
|
+
* @returns {Promise<string[]>} ids newly queued.
|
|
494
|
+
*/
|
|
495
|
+
export async function reconcile(sessionQuery, state, { now = Date.now, cwd, excludeSessionId, maxAgeMs = 7 * DAY_MS } = {}) {
|
|
496
|
+
const queued = new Set(state.pending().map((job) => job.sessionId));
|
|
497
|
+
const added = [];
|
|
498
|
+
const sessions = await sessionQuery.listSessions();
|
|
499
|
+
/* A full scan also forgets the watermarks of deleted sessions (an empty listing is not trusted). */
|
|
500
|
+
if (cwd === undefined && sessions.length > 0) await state.pruneSessions(sessions.map(({ header }) => header.id));
|
|
501
|
+
for (const { header, live } of sessions) {
|
|
502
|
+
if (live || !isEligible(header) || header.id === excludeSessionId || queued.has(header.id)) continue;
|
|
503
|
+
if (cwd !== undefined && !sameProject(header.cwd, cwd)) continue;
|
|
504
|
+
if ((header.createdAt ?? 0) < Math.max(now() - maxAgeMs, state.installedAt ?? 0)) continue;
|
|
505
|
+
const events = await sessionQuery.listEvents(header.id);
|
|
506
|
+
const last = events.at(-1);
|
|
507
|
+
const seq = last?.seq ?? 0;
|
|
508
|
+
const watermark = state.watermark(header.id);
|
|
509
|
+
if (seq <= watermark) continue;
|
|
510
|
+
if (!isSubstantive(events.filter((event) => event.seq > watermark).reduce(accumulate, emptyActivity()))) continue;
|
|
511
|
+
await state.enqueue({ sessionId: header.id, cwd: header.cwd, endedAt: last?.time ?? header.createdAt ?? now(), seq });
|
|
512
|
+
added.push(header.id);
|
|
513
|
+
}
|
|
514
|
+
return added;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Identify a stored hidden session as a dreaming run: its first user message
|
|
519
|
+
* opens with a dreaming prompt marker. Returns its creation time, or null
|
|
520
|
+
* when it is not ours or cannot be read.
|
|
521
|
+
* @param {string} dir - `<sessions>/_no-cwd/<id>`.
|
|
522
|
+
* @returns {Promise<number | null>}
|
|
523
|
+
*/
|
|
524
|
+
async function dreamSessionCreatedAt(dir) {
|
|
525
|
+
let raw;
|
|
526
|
+
try {
|
|
527
|
+
const file = (await readdir(dir)).find((name) => /^session\..*\.jsonl(\.zstd)?$/.test(name));
|
|
528
|
+
if (file === undefined) return null;
|
|
529
|
+
if ((await stat(join(dir, file))).size > MAX_DREAM_SESSION_BYTES) return null;
|
|
530
|
+
raw = await readFile(join(dir, file));
|
|
531
|
+
if (file.endsWith(".zstd")) {
|
|
532
|
+
if (typeof zlib.zstdDecompress !== "function") return null;
|
|
533
|
+
raw = await promisify(zlib.zstdDecompress)(raw);
|
|
534
|
+
}
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
let createdAt = 0;
|
|
539
|
+
for (const line of raw.toString("utf8").split("\n")) {
|
|
540
|
+
if (line.trim() === "") continue;
|
|
541
|
+
let event;
|
|
542
|
+
try {
|
|
543
|
+
event = JSON.parse(line);
|
|
544
|
+
} catch {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
if (event.type === "session") createdAt = event.createdAt ?? 0;
|
|
548
|
+
else if (event.type === "user/message") {
|
|
549
|
+
const text = (event.data?.content ?? []).filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
550
|
+
return DREAM_PROMPT_MARKERS.some((marker) => text.startsWith(marker)) ? createdAt : null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Delete the stored sessions of old dreaming runs, keeping the newest `keep`.
|
|
558
|
+
* Runs are found on disk, not in plugin state, so sessions left by a reset or
|
|
559
|
+
* reinstalled plugin are cleaned too; any other hidden session is untouched.
|
|
560
|
+
* @param {string} sessionsRoot - `<DSH home>/sessions`.
|
|
561
|
+
* @param {{ keep?: number, cache?: Map<string, number | null> }} [options]
|
|
562
|
+
* `cache` remembers what each session id turned out to be across calls.
|
|
563
|
+
*/
|
|
564
|
+
export async function pruneDreamSessions(sessionsRoot, { keep = 50, cache = new Map() } = {}) {
|
|
565
|
+
const root = join(sessionsRoot, "_no-cwd");
|
|
566
|
+
let ids;
|
|
567
|
+
try {
|
|
568
|
+
ids = (await readdir(root)).filter((id) => /^session-[\w-]+$/.test(id));
|
|
569
|
+
} catch {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const ours = [];
|
|
573
|
+
for (const id of ids) {
|
|
574
|
+
if (!cache.has(id)) cache.set(id, await dreamSessionCreatedAt(join(root, id)));
|
|
575
|
+
const createdAt = cache.get(id);
|
|
576
|
+
if (createdAt !== null) ours.push({ id, createdAt });
|
|
577
|
+
}
|
|
578
|
+
ours.sort((a, b) => a.createdAt - b.createdAt);
|
|
579
|
+
for (const { id } of ours.slice(0, Math.max(0, ours.length - keep))) {
|
|
580
|
+
await rm(join(root, id), { recursive: true, force: true });
|
|
581
|
+
cache.delete(id);
|
|
582
|
+
}
|
|
583
|
+
}
|
package/lib/selection.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which model and reasoning effort a dreaming run uses.
|
|
3
|
+
* A chosen provider/model may carry a chosen effort (empty = the model's own
|
|
4
|
+
* default); the default model follows DSH's default selection as a whole.
|
|
5
|
+
* @param config - live config references (`dreamProvider`, `dreamModel`, `dreamEffort`).
|
|
6
|
+
* @param [agentDefaultModel] - `ctx.agentDefaultModel`, when mounted.
|
|
7
|
+
* @returns {{ provider: string, model: string, reasoningEffort?: string } | undefined}
|
|
8
|
+
*/
|
|
9
|
+
export function resolveSelection(config, agentDefaultModel) {
|
|
10
|
+
const provider = config.dreamProvider.get();
|
|
11
|
+
const model = config.dreamModel.get();
|
|
12
|
+
if (provider === "" || model === "") return agentDefaultModel?.currentSelection();
|
|
13
|
+
const effort = config.dreamEffort.get();
|
|
14
|
+
return effort === "" ? { provider, model } : { provider, model, reasoningEffort: effort };
|
|
15
|
+
}
|