mover-os 4.7.8 → 4.7.10
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/README.md +34 -24
- package/install.js +2229 -204
- package/package.json +3 -2
- package/src/dashboard/build.js +41 -3
- package/src/dashboard/lib/active-context-parser.js +16 -4
- package/src/dashboard/lib/agent-session.js +70 -49
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/daily-note-resolver.js +20 -3
- package/src/dashboard/lib/date-utils.js +9 -1
- package/src/dashboard/lib/distribution-parser.js +69 -10
- package/src/dashboard/lib/drift-history.js +6 -1
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +4 -1
- package/src/dashboard/lib/engine-writer.js +157 -11
- package/src/dashboard/lib/goal-forecast.js +154 -31
- package/src/dashboard/lib/library-indexer-v2.js +14 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +0 -0
- package/src/dashboard/lib/memory-index.js +61 -12
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/regenerate-manifest.js +0 -0
- package/src/dashboard/lib/run-registry.js +75 -15
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +3 -0
- package/src/dashboard/lib/suggested-now.js +2 -2
- package/src/dashboard/lib/transcript-parser.js +48 -8
- package/src/dashboard/server.js +422 -44
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/system/counts.json +7 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
const fs = require("fs");
|
|
17
17
|
const path = require("path");
|
|
18
|
+
const crypto = require("crypto");
|
|
18
19
|
const { tokenize, termFreq, buildVocab } = require("./memory-text");
|
|
19
20
|
const { enumerateSessions, parseTranscript, deriveProject } = require("./transcript-parser");
|
|
20
21
|
const { parseSessionLogs } = require("./session-log-parser");
|
|
@@ -46,9 +47,34 @@ function ensureDirs() {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
function atomicWrite(file, obj) {
|
|
49
|
-
|
|
50
|
+
// terra T-MH-2: a fixed `file + ".tmp"` name means two concurrent SessionEnd
|
|
51
|
+
// hooks writing the same segment/manifest share one temp path and can interleave
|
|
52
|
+
// into a torn file. A per-write unique temp name makes each write's rename
|
|
53
|
+
// independent (the last full write wins cleanly; no half-written temp survives).
|
|
54
|
+
const tmp = file + "." + process.pid + "-" + crypto.randomBytes(4).toString("hex") + ".tmp";
|
|
50
55
|
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
51
|
-
fs.renameSync(tmp, file);
|
|
56
|
+
try { fs.renameSync(tmp, file); }
|
|
57
|
+
catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// terra T-MH-1: safeReadJson() collapses "file absent" and "file present but
|
|
61
|
+
// MALFORMED" both to null, so `safeReadJson(segFile) || newSeg()` would overwrite
|
|
62
|
+
// a corrupt segment with an empty one — discarding every indexed doc for that
|
|
63
|
+
// month. Distinguish the two: a MISSING segment is a fresh newSeg(); a present-
|
|
64
|
+
// but-unparseable segment THROWS, so the fail-open SessionEnd hook skips indexing
|
|
65
|
+
// this one session and PRESERVES the existing file rather than nuking it.
|
|
66
|
+
function readSegOrNew(segFile) {
|
|
67
|
+
let raw;
|
|
68
|
+
try { raw = fs.readFileSync(segFile, "utf8"); }
|
|
69
|
+
catch (e) { if (e.code === "ENOENT") return newSeg(); throw e; }
|
|
70
|
+
let parsed;
|
|
71
|
+
try { parsed = JSON.parse(raw); }
|
|
72
|
+
catch (_) { throw Object.assign(new Error("memory segment is malformed; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" }); }
|
|
73
|
+
// A parsed-but-wrong-shape file is also not safe to fold into — treat as corrupt.
|
|
74
|
+
if (!parsed || typeof parsed !== "object" || !parsed.docs || !parsed.postings) {
|
|
75
|
+
throw Object.assign(new Error("memory segment has an unexpected shape; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" });
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
80
|
function monthOf(ts, fallbackMs) {
|
|
@@ -377,19 +403,43 @@ async function indexSession(file, opts = {}) {
|
|
|
377
403
|
if (!man) return { error: "no index — run --backfill first" };
|
|
378
404
|
man.docFreq = man.docFreq || {}; man.linkFreq = man.linkFreq || {}; man.indexedSessions = man.indexedSessions || {};
|
|
379
405
|
const vault = opts.vault || man.vault;
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
// when not passed — the SessionEnd hook calls indexSession(file, {}), and without this every
|
|
383
|
-
// live-indexed session would be project=null, making the cross-project scope guard a no-op.
|
|
384
|
-
const project = opts.project || deriveProject(path.basename(path.dirname(file))) || null;
|
|
406
|
+
const pathSessionId = path.basename(file).replace(/\.jsonl$/, "");
|
|
407
|
+
const pathProject = deriveProject(path.basename(path.dirname(file))) || null;
|
|
385
408
|
let mtime = null, size = 0;
|
|
386
409
|
try { const st = fs.statSync(file); mtime = st.mtimeMs; size = st.size; } catch {}
|
|
387
|
-
const prev = man.indexedSessions[sessionId];
|
|
388
|
-
if (prev && mtime && prev.mtime === mtime && !opts.force) return { skipped: true, sessionId };
|
|
389
410
|
|
|
390
|
-
|
|
411
|
+
// Parse before selecting identity. Claude stores sessions under an encoded project directory, but
|
|
412
|
+
// Codex stores rollouts under date folders and carries the real session id + cwd in session_meta.
|
|
413
|
+
const r = await parseTranscript(file, {
|
|
414
|
+
vocab: buildVocab(vault),
|
|
415
|
+
sessionId: opts.sessionId,
|
|
416
|
+
project: opts.project,
|
|
417
|
+
agent: opts.agent,
|
|
418
|
+
});
|
|
419
|
+
const sessionId = opts.sessionId || r.sessionId || pathSessionId;
|
|
420
|
+
const project = opts.project || r.project || pathProject;
|
|
391
421
|
if (r.error) return { error: "parse failed", sessionId };
|
|
392
422
|
|
|
423
|
+
let reindexed = false;
|
|
424
|
+
// Remove a pre-fix Codex entry keyed by the rollout filename. Without this migration the same
|
|
425
|
+
// transcript survives twice: once under rollout-* and once under its canonical session id.
|
|
426
|
+
if (pathSessionId !== sessionId) {
|
|
427
|
+
const legacy = man.indexedSessions[pathSessionId];
|
|
428
|
+
if (legacy && legacy.month) {
|
|
429
|
+
const legacyFile = path.join(SEG_DIR, "raw-" + legacy.month + ".json");
|
|
430
|
+
const legacySeg = safeReadJson(legacyFile);
|
|
431
|
+
if (legacySeg && legacySeg.docs["session:" + pathSessionId]) {
|
|
432
|
+
removeDocContribution(legacySeg, "session:" + pathSessionId, man);
|
|
433
|
+
atomicWrite(legacyFile, legacySeg);
|
|
434
|
+
}
|
|
435
|
+
delete man.indexedSessions[pathSessionId];
|
|
436
|
+
reindexed = true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const prev = man.indexedSessions[sessionId];
|
|
441
|
+
if (prev && mtime && prev.mtime === mtime && !opts.force && !reindexed) return { skipped: true, sessionId };
|
|
442
|
+
|
|
393
443
|
const month = monthOf(r.firstTs, mtime);
|
|
394
444
|
const id = "session:" + sessionId;
|
|
395
445
|
|
|
@@ -398,7 +448,6 @@ async function indexSession(file, opts = {}) {
|
|
|
398
448
|
// lives in the PREVIOUSLY recorded month's segment — remove it from THERE too, else it orphans
|
|
399
449
|
// (ghost doc) and permanently inflates docFreq/totalDocs/linkFreq, degrading IDF until a full
|
|
400
450
|
// --backfill. Only fires on a genuine month change; the common same-month re-index skips it.
|
|
401
|
-
let reindexed = false;
|
|
402
451
|
const prevMonth = prev && prev.month;
|
|
403
452
|
if (prevMonth && prevMonth !== month) {
|
|
404
453
|
const prevSegFile = path.join(SEG_DIR, "raw-" + prevMonth + ".json");
|
|
@@ -411,7 +460,7 @@ async function indexSession(file, opts = {}) {
|
|
|
411
460
|
}
|
|
412
461
|
|
|
413
462
|
const segFile = path.join(SEG_DIR, "raw-" + month + ".json");
|
|
414
|
-
const seg =
|
|
463
|
+
const seg = readSegOrNew(segFile); // terra T-MH-1: never clobber a malformed segment
|
|
415
464
|
|
|
416
465
|
// remove old contribution (same-month re-index case) — scan only this segment's postings.
|
|
417
466
|
if (seg.docs[id]) { removeDocContribution(seg, id, man); reindexed = true; }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* pid-markers.js — crash-orphan bookkeeping shared by AgentSessionManager
|
|
5
|
+
* (Chat/onboarding) and RunRegistry (Run tab).
|
|
6
|
+
*
|
|
7
|
+
* Every dashboard-spawned agent child is detached into its own process group
|
|
8
|
+
* and recorded here as one marker file per pid, tagged with the owning
|
|
9
|
+
* dashboard's pid. On startup each manager reaps any group whose OWNER is
|
|
10
|
+
* gone — children escaped by a crashed dashboard (kill -9, OOM, update crash)
|
|
11
|
+
* that no graceful shutdown path could ever reach.
|
|
12
|
+
*
|
|
13
|
+
* Extracted verbatim from agent-session.js (MF2, Codex r2 hardening) so the
|
|
14
|
+
* Run tab gets the same guarantee. One file per pid: no shared-file
|
|
15
|
+
* read-modify-write race across concurrent dashboards; a corrupt write only
|
|
16
|
+
* loses its own marker. Writes are atomic (tmp + rename). Both managers share
|
|
17
|
+
* ONE directory on purpose: whichever starts first reaps the other's orphans.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const os = require("os");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
|
|
24
|
+
const MARKERS_DIR = path.join(os.homedir(), ".mover", "dashboard", "agent-pids");
|
|
25
|
+
|
|
26
|
+
function markerPath(pid) { return path.join(MARKERS_DIR, String(pid) + ".json"); }
|
|
27
|
+
|
|
28
|
+
function writeMarker(pid, data) {
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(MARKERS_DIR, { recursive: true });
|
|
31
|
+
const f = markerPath(pid), tmp = f + ".tmp";
|
|
32
|
+
fs.writeFileSync(tmp, JSON.stringify(data));
|
|
33
|
+
fs.renameSync(tmp, f); // atomic: a reader never sees a half-written marker
|
|
34
|
+
} catch (_) {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function removeMarker(pid) { try { fs.unlinkSync(markerPath(pid)); } catch (_) {} }
|
|
38
|
+
|
|
39
|
+
function readMarkers() {
|
|
40
|
+
let files; try { files = fs.readdirSync(MARKERS_DIR); } catch (_) { return []; }
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const fn of files) {
|
|
43
|
+
if (!fn.endsWith(".json")) continue;
|
|
44
|
+
try { out.push(JSON.parse(fs.readFileSync(path.join(MARKERS_DIR, fn), "utf8"))); } catch (_) {}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAlive(pid) {
|
|
50
|
+
if (!pid) return false;
|
|
51
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// True if ANY member of the process group still exists (not just the leader) —
|
|
55
|
+
// process.kill(-pid, 0) throws ESRCH only when the whole group is gone.
|
|
56
|
+
function groupAlive(pid) {
|
|
57
|
+
if (!pid) return false;
|
|
58
|
+
try { process.kill(-pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function killGroup(pid, sig) {
|
|
62
|
+
if (!pid) return false;
|
|
63
|
+
try { process.kill(-pid, sig); return true; }
|
|
64
|
+
catch (_) { try { process.kill(pid, sig); return true; } catch (__) { return false; } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Reap any recorded group whose owning dashboard is gone. Idempotent; safe to
|
|
68
|
+
// call from every manager's constructor (they share the directory).
|
|
69
|
+
function reapOrphans() {
|
|
70
|
+
for (const m of readMarkers()) {
|
|
71
|
+
if (!m || !m.pid) continue;
|
|
72
|
+
// Keep markers whose owning dashboard is still alive (this process OR a
|
|
73
|
+
// concurrent one). Only a marker whose owner is GONE is a real orphan.
|
|
74
|
+
if (m.ownerPid && isAlive(m.ownerPid)) continue;
|
|
75
|
+
if (groupAlive(m.pid)) killGroup(m.pid, "SIGKILL"); // owner gone → orphaned group
|
|
76
|
+
removeMarker(m.pid);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { MARKERS_DIR, writeMarker, removeMarker, readMarkers, isAlive, groupAlive, killGroup, reapOrphans };
|
|
File without changes
|
|
@@ -29,9 +29,11 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
const { spawn } = require("child_process");
|
|
32
|
+
const { writeMarker, removeMarker, groupAlive, killGroup, reapOrphans } = require("./pid-markers");
|
|
32
33
|
|
|
33
34
|
const DEFAULTS = {
|
|
34
35
|
maxRuns: 40, // retain at most N runs (live + finished)
|
|
36
|
+
maxConcurrent: 4, // live child processes at once; excess requests are refused
|
|
35
37
|
maxBuffer: 1024 * 1024, // 1 MB per-run output cap (head dropped, tail kept)
|
|
36
38
|
finishedTtlMs: 6 * 60 * 60 * 1000, // evict finished runs 6h after they end
|
|
37
39
|
idleSweepMs: 5 * 60 * 1000, // sweep cadence
|
|
@@ -43,10 +45,21 @@ class RunRegistry {
|
|
|
43
45
|
this.opt = { ...DEFAULTS, ...opts };
|
|
44
46
|
this.runs = new Map(); // id -> run record
|
|
45
47
|
this._seq = 0;
|
|
48
|
+
// R5-A: reap children escaped by a crashed dashboard (same MF2 guarantee
|
|
49
|
+
// AgentSessionManager has; the marker dir is shared so either manager's
|
|
50
|
+
// startup cleans up both kinds of orphan).
|
|
51
|
+
reapOrphans();
|
|
46
52
|
this._sweep = setInterval(() => this._evict(), this.opt.idleSweepMs);
|
|
47
53
|
if (this._sweep.unref) this._sweep.unref();
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/** Live (running/stopping) child count — the concurrency the cap guards. */
|
|
57
|
+
liveCount() {
|
|
58
|
+
let n = 0;
|
|
59
|
+
for (const r of this.runs.values()) if (r.status === "running" || r.status === "stopping") n++;
|
|
60
|
+
return n;
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/**
|
|
51
64
|
* Spawn a tracked run. The CALLER builds the command (agentCommand) and the
|
|
52
65
|
* final prompt (buildAgentPrompt); this owns the process + buffer lifecycle.
|
|
@@ -54,6 +67,13 @@ class RunRegistry {
|
|
|
54
67
|
* Returns the run record (its `.id` is the handle for subscribe/get/stop).
|
|
55
68
|
*/
|
|
56
69
|
start(spec) {
|
|
70
|
+
// Concurrency cap (R5-A): every run is a real long-lived CLI child with
|
|
71
|
+
// full tool access; a buggy retry loop must not fork-bomb the machine.
|
|
72
|
+
if (this.liveCount() >= this.opt.maxConcurrent) {
|
|
73
|
+
const err = new Error(`${this.opt.maxConcurrent} agents are already running. Stop one or let it finish first.`);
|
|
74
|
+
err.code = "RUN_LIMIT";
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
57
77
|
const id = `run_${process.pid}_${++this._seq}`;
|
|
58
78
|
const run = {
|
|
59
79
|
id,
|
|
@@ -73,17 +93,26 @@ class RunRegistry {
|
|
|
73
93
|
|
|
74
94
|
let child;
|
|
75
95
|
try {
|
|
76
|
-
|
|
96
|
+
// detached: own process group, so Stop/shutdown can signal the whole
|
|
97
|
+
// tree (a run's tool calls spawn grandchildren the bare child.kill()
|
|
98
|
+
// never reached), and the pid marker lets a post-crash startup reap it.
|
|
99
|
+
child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
77
100
|
} catch (e) {
|
|
78
101
|
this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${e && e.message}\n`);
|
|
79
102
|
this._finish(run, "failed", null);
|
|
80
103
|
return run;
|
|
81
104
|
}
|
|
82
105
|
run.child = child;
|
|
106
|
+
if (child.pid) writeMarker(child.pid, { pid: child.pid, ownerPid: process.pid, at: Date.now(), kind: "run" });
|
|
83
107
|
|
|
84
108
|
const timeout = setTimeout(() => {
|
|
109
|
+
// Mark stopping BEFORE the kill so the close handler reports "stopped",
|
|
110
|
+
// not a false "done" (terra run-path F2).
|
|
111
|
+
run.status = "stopping";
|
|
85
112
|
this._append(run, "\n[mover-studio] timeout after 10 minutes; stopping agent.\n", true);
|
|
86
|
-
|
|
113
|
+
killGroup(child.pid, "SIGTERM");
|
|
114
|
+
const esc = setTimeout(() => { if (groupAlive(child.pid)) killGroup(child.pid, "SIGKILL"); }, 2500);
|
|
115
|
+
if (esc.unref) esc.unref();
|
|
87
116
|
}, this.opt.timeoutMs);
|
|
88
117
|
if (timeout.unref) timeout.unref();
|
|
89
118
|
|
|
@@ -102,12 +131,14 @@ class RunRegistry {
|
|
|
102
131
|
this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${err && err.message}\n`, true);
|
|
103
132
|
this._finish(run, "failed", null);
|
|
104
133
|
});
|
|
105
|
-
child.on("close", (code) => {
|
|
134
|
+
child.on("close", (code, signal) => {
|
|
106
135
|
clearTimeout(timeout);
|
|
107
|
-
this._append(run, `\n[mover-studio] agent exited with code ${code}\n`, true);
|
|
108
|
-
// A run we SIGTERM'd
|
|
109
|
-
//
|
|
110
|
-
|
|
136
|
+
this._append(run, `\n[mover-studio] agent exited with code ${code}${signal ? ` (signal ${signal})` : ""}\n`, true);
|
|
137
|
+
// A run we SIGTERM'd (stop OR timeout) keeps "stopping" -> "stopped". A
|
|
138
|
+
// clean exit (code 0) is "done"; ANY other termination — a non-zero code OR
|
|
139
|
+
// code==null from a signal we did not send — is "exited", never a false
|
|
140
|
+
// "done" (terra run-path F2: a signal-killed run was reported as success).
|
|
141
|
+
const status = run.status === "stopping" ? "stopped" : code === 0 ? "done" : "exited";
|
|
111
142
|
this._finish(run, status, code);
|
|
112
143
|
});
|
|
113
144
|
|
|
@@ -123,6 +154,10 @@ class RunRegistry {
|
|
|
123
154
|
// Snapshot + register atomically (no I/O between) so no chunk is lost.
|
|
124
155
|
cb({ type: "replay", text: run.output, status: run.status, exit: run.exit });
|
|
125
156
|
if (run.status === "running" || run.status === "stopping") {
|
|
157
|
+
// Cap live subscribers per run so a flood of stream connections can't pile
|
|
158
|
+
// up callbacks/sockets (terra run-path F3). Over the cap, the client still
|
|
159
|
+
// got the full replay above; end it rather than live-stream.
|
|
160
|
+
if (run.subscribers.size >= 64) { cb({ type: "end", status: run.status, exit: run.exit }); return () => {}; }
|
|
126
161
|
run.subscribers.add(cb);
|
|
127
162
|
return () => run.subscribers.delete(cb);
|
|
128
163
|
}
|
|
@@ -130,15 +165,17 @@ class RunRegistry {
|
|
|
130
165
|
return () => {};
|
|
131
166
|
}
|
|
132
167
|
|
|
133
|
-
/** Really stop a run (SIGTERM the child
|
|
168
|
+
/** Really stop a run (SIGTERM the child's whole process group — a run's
|
|
169
|
+
* tool calls spawn grandchildren). Distinct from a client merely
|
|
134
170
|
* detaching its stream ("stop watching"). */
|
|
135
171
|
stop(id) {
|
|
136
172
|
const run = this.runs.get(id);
|
|
137
173
|
if (!run || !run.child || (run.status !== "running")) return false;
|
|
138
174
|
run.status = "stopping";
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
175
|
+
const pid = run.child.pid;
|
|
176
|
+
killGroup(pid, "SIGTERM");
|
|
177
|
+
// Escalate if the group ignores SIGTERM.
|
|
178
|
+
const t = setTimeout(() => { if (run.status === "stopping" && groupAlive(pid)) killGroup(pid, "SIGKILL"); }, 2500);
|
|
142
179
|
if (t.unref) t.unref();
|
|
143
180
|
return true;
|
|
144
181
|
}
|
|
@@ -149,11 +186,30 @@ class RunRegistry {
|
|
|
149
186
|
return [...this.runs.values()].sort((a, b) => b.started - a.started).map((r) => this._summary(r, false));
|
|
150
187
|
}
|
|
151
188
|
|
|
152
|
-
|
|
189
|
+
/** Kill every live run's process group and WAIT for it to die, escalating
|
|
190
|
+
* to SIGKILL after graceMs — mirrors AgentSessionManager.shutdownAll (MF1)
|
|
191
|
+
* so the server's hard-exit timer can't strand a stubborn child. If a
|
|
192
|
+
* group somehow survives SIGKILL, its marker stays on disk so the next
|
|
193
|
+
* startup reaps it — never a silent orphan. */
|
|
194
|
+
shutdownAll(graceMs = 1500) {
|
|
153
195
|
if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
196
|
+
const live = [...this.runs.values()].filter((r) => r.child && (r.status === "running" || r.status === "stopping"));
|
|
197
|
+
return Promise.all(live.map((r) => new Promise((resolve) => {
|
|
198
|
+
const pid = r.child.pid;
|
|
199
|
+
killGroup(pid, "SIGTERM");
|
|
200
|
+
const start = Date.now();
|
|
201
|
+
let killed = false;
|
|
202
|
+
const tick = () => {
|
|
203
|
+
if (!groupAlive(pid)) { removeMarker(pid); return resolve(); }
|
|
204
|
+
if (!killed && Date.now() - start >= graceMs) { killGroup(pid, "SIGKILL"); killed = true; }
|
|
205
|
+
if (killed && Date.now() - start >= graceMs + 700) {
|
|
206
|
+
if (!groupAlive(pid)) removeMarker(pid);
|
|
207
|
+
return resolve();
|
|
208
|
+
}
|
|
209
|
+
const t = setTimeout(tick, 150); if (t.unref) t.unref();
|
|
210
|
+
};
|
|
211
|
+
tick();
|
|
212
|
+
})));
|
|
157
213
|
}
|
|
158
214
|
|
|
159
215
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
@@ -185,6 +241,10 @@ class RunRegistry {
|
|
|
185
241
|
// "failed"/"stopped" with the close code (null -> "done") and emits a
|
|
186
242
|
// second "end". First finish wins. (Found by a Codex review, 2026-06-30.)
|
|
187
243
|
if (run.ended) return;
|
|
244
|
+
// Drop the crash marker only once the whole GROUP is gone; if a tool
|
|
245
|
+
// grandchild outlived the leader, the marker survives and the next
|
|
246
|
+
// dashboard startup reaps it.
|
|
247
|
+
if (run.child && run.child.pid && !groupAlive(run.child.pid)) removeMarker(run.child.pid);
|
|
188
248
|
run.status = status;
|
|
189
249
|
run.exit = exit;
|
|
190
250
|
run.ended = Date.now();
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// backfill.js — CORE-LOOP PHASE 2 (SCHEMA-SPEC v0.2 section 4, migration).
|
|
4
|
+
// Turns a vault COPY's existing records into v0.2 events. Copy-first is the
|
|
5
|
+
// owner-approved default #5: nothing in this module is wired to any workflow,
|
|
6
|
+
// hook, or server route, and applyState (dryrun.js) refuses a non-pristine
|
|
7
|
+
// target. The live vault is never touched until the owner has inspected a
|
|
8
|
+
// dry-run manifest on a copy.
|
|
9
|
+
//
|
|
10
|
+
// WHAT v1 MIGRATES (mechanically faithful sources only):
|
|
11
|
+
// - ~/.mover/pattern-manifest.json override_history[] -> override.logged
|
|
12
|
+
// - Dailies "## Tasks" checkboxes -> plan.observed + plan.ticked
|
|
13
|
+
//
|
|
14
|
+
// WHAT v1 DOES NOT MIGRATE (spec amendment, recorded 2026-07-11): the v0.1
|
|
15
|
+
// spec table sourced receipt.filed from "Metrics_Log receipt lines", but the
|
|
16
|
+
// real Metrics_Log.md is a Fitbit vitality analysis with no receipts section
|
|
17
|
+
// — its dated lines are sleep scores. Fabricating receipts from them (or
|
|
18
|
+
// from session-log prose via keyword heuristics) would inflate the record
|
|
19
|
+
// this system exists to keep honest. Undercount over inflate: prose receipts
|
|
20
|
+
// are declared non-recoverable; receipt.filed/ship.published begin at
|
|
21
|
+
// Close-time capture going forward. Every skip is returned in `skipped`
|
|
22
|
+
// with a reason — absence is reported, never papered over.
|
|
23
|
+
//
|
|
24
|
+
// Every migrated event carries source {path, sourceHash, parserVersion,
|
|
25
|
+
// locator} per v0.2 amendment 2 — a locator for audit, never an identity.
|
|
26
|
+
|
|
27
|
+
const fs = require("fs");
|
|
28
|
+
const path = require("path");
|
|
29
|
+
const { sha256Hex } = require("./event-log");
|
|
30
|
+
const { overrideLogged, planTicked, planObserved } = require("./events");
|
|
31
|
+
|
|
32
|
+
// v2: literal NUL bytes dropped from identity + variable-length backtick
|
|
33
|
+
// fences parsed per CommonMark (terra round 3 findings 2 and 3).
|
|
34
|
+
const PARSER_VERSION = 2;
|
|
35
|
+
|
|
36
|
+
// ── time ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
// Day-precision source dates land at UTC noon: projections.dateKeyFromTs is
|
|
39
|
+
// UTC, so noon round-trips to the same YYYY-MM-DD for any writer zone within
|
|
40
|
+
// +/-11h, without pretending we know the time of day.
|
|
41
|
+
function tsFromDateKey(dateKey) {
|
|
42
|
+
const t = Date.parse(`${dateKey}T12:00:00Z`);
|
|
43
|
+
return Number.isFinite(t) ? t : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Datetime strings ("2026-04-20T11:11") parse as local time by JS spec —
|
|
47
|
+
// deterministic per machine, which is all a backfill needs.
|
|
48
|
+
function tsFromLoose(value) {
|
|
49
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
50
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) return tsFromDateKey(value.trim());
|
|
51
|
+
const t = Date.parse(value);
|
|
52
|
+
return Number.isFinite(t) ? t : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── shared ──────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function sourceFor(relPath, fileText, locator) {
|
|
58
|
+
return {
|
|
59
|
+
path: relPath,
|
|
60
|
+
sourceHash: sha256Hex(fileText),
|
|
61
|
+
parserVersion: PARSER_VERSION,
|
|
62
|
+
locator,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Code spans open with a run of N backticks and close at the next run of
|
|
67
|
+
// EXACTLY N (CommonMark). The old single-backtick regex corrupted
|
|
68
|
+
// ``a`b``-style spans (terra round 3 finding 3). An unclosed run stays
|
|
69
|
+
// literal text.
|
|
70
|
+
function extractCodeSpans(text, codeSpans) {
|
|
71
|
+
let out = "";
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < text.length) {
|
|
74
|
+
if (text[i] !== "`") { out += text[i]; i += 1; continue; }
|
|
75
|
+
let n = 0;
|
|
76
|
+
while (text[i + n] === "`") n += 1;
|
|
77
|
+
let j = i + n;
|
|
78
|
+
let close = -1;
|
|
79
|
+
while (j < text.length) {
|
|
80
|
+
if (text[j] !== "`") { j += 1; continue; }
|
|
81
|
+
let m = 0;
|
|
82
|
+
while (text[j + m] === "`") m += 1;
|
|
83
|
+
if (m === n) { close = j; break; }
|
|
84
|
+
j += m;
|
|
85
|
+
}
|
|
86
|
+
if (close === -1) { out += text.slice(i, i + n); i += n; continue; }
|
|
87
|
+
codeSpans.push(text.slice(i + n, close));
|
|
88
|
+
out += "\u0000" + (codeSpans.length - 1) + "\u0000";
|
|
89
|
+
i = close + n;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeTask(text) {
|
|
95
|
+
// Markdown canonicalization (terra attack 7 + re-verdict #7): `Send
|
|
96
|
+
// **proposal**` and `Send proposal` are the same task; formatting must not
|
|
97
|
+
// fork identity. But a CODE SPAN is literal text - stripping its backticks
|
|
98
|
+
// AND its inner asterisks merged `Run \`a*b*\`` with `Run a*b` (distinct
|
|
99
|
+
// tasks). Code spans are held out verbatim (minus the backticks), emphasis
|
|
100
|
+
// stripping applies only OUTSIDE them; links tolerate one level of nested
|
|
101
|
+
// brackets in the label.
|
|
102
|
+
// Literal NUL bytes are dropped BEFORE the sentinel pass (terra round 3
|
|
103
|
+
// finding 2: task text containing a literal NUL-digit-NUL sequence
|
|
104
|
+
// resolved as a sentinel and collapsed to a colliding identity). NUL is
|
|
105
|
+
// not representable markdown content, so dropping it is canonicalization,
|
|
106
|
+
// not data loss.
|
|
107
|
+
const codeSpans = [];
|
|
108
|
+
let t = extractCodeSpans(text.replace(/\u0000/g, ""), codeSpans);
|
|
109
|
+
t = t
|
|
110
|
+
.replace(/\[((?:[^\[\]]|\[[^\]]*\])*)\]\([^)]*\)/g, "$1") // [text [nested]](url) -> text [nested]
|
|
111
|
+
.replace(/(\*\*|__|~~)/g, "")
|
|
112
|
+
.replace(/(^|\s)[*_](\S)/g, "$1$2") // leading emphasis marker on a word
|
|
113
|
+
.replace(/(\S)[*_](\s|$)/g, "$1$2"); // trailing emphasis marker on a word
|
|
114
|
+
t = t.replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)] || "");
|
|
115
|
+
return t.trim().replace(/\s+/g, " ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stable across backfill runs AND across [x]/[ ] flips of the same task on
|
|
119
|
+
// the same note date — the taskId is (date, text), never checkbox state.
|
|
120
|
+
function taskIdFor(noteDate, text) {
|
|
121
|
+
return sha256Hex(`${noteDate}\n${normalizeTask(text)}`).slice(0, 16);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── override_history -> override.logged ─────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
// Field fallbacks mirror lib/override-summary.js normalizeOverrides — the
|
|
127
|
+
// manifest has grown several field spellings over its life. A missing text
|
|
128
|
+
// field becomes the composed absence "not recorded" (the constructor requires
|
|
129
|
+
// non-empty strings; an honest placeholder beats dropping a real override).
|
|
130
|
+
// A missing/unparseable date is different: a fabricated timestamp would
|
|
131
|
+
// corrupt every date-keyed projection, so the entry is SKIPPED with a reason.
|
|
132
|
+
function backfillOverrides(manifestText, { manifestPath = "pattern-manifest.json" } = {}) {
|
|
133
|
+
const events = [];
|
|
134
|
+
const skipped = [];
|
|
135
|
+
|
|
136
|
+
let manifest;
|
|
137
|
+
try {
|
|
138
|
+
manifest = JSON.parse(manifestText);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { events, skipped: [{ locator: manifestPath, reason: `manifest is not valid JSON: ${e.message}` }] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const raw = (manifest && (manifest.override_history || manifest.overrideHistory)) || [];
|
|
144
|
+
if (!Array.isArray(raw)) {
|
|
145
|
+
return { events, skipped: [{ locator: `${manifestPath}#override_history`, reason: "override_history is not an array" }] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
raw.forEach((o, i) => {
|
|
149
|
+
const locator = `override_history[${i}]`;
|
|
150
|
+
if (o === null || typeof o !== "object") {
|
|
151
|
+
skipped.push({ locator, reason: "entry is not an object" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const ts = tsFromLoose(o.date || o.timestamp);
|
|
155
|
+
if (ts === null) {
|
|
156
|
+
skipped.push({ locator, reason: `no parseable date (got ${JSON.stringify(o.date || o.timestamp || null)})` });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
160
|
+
events.push(
|
|
161
|
+
overrideLogged(
|
|
162
|
+
{
|
|
163
|
+
what: str(o.what) || str(o.action) || str(o.task) || "not recorded",
|
|
164
|
+
over: str(o.over) || str(o.plan_item) || str(o.conflicted_with) || "not recorded",
|
|
165
|
+
reason: str(o.reason) || str(o.user_override) || str(o.justification) || "not recorded",
|
|
166
|
+
level: typeof o.level === "number" ? o.level : str(o.level) || "UNKNOWN",
|
|
167
|
+
},
|
|
168
|
+
{ ts, source: sourceFor(manifestPath, manifestText, locator) }
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return { events, skipped };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Daily Note "## Tasks" -> plan.observed + plan.ticked ────────────────────
|
|
177
|
+
|
|
178
|
+
const NOTE_NAME_RE = /^Daily - (\d{4}-\d{2}-\d{2})\.md$/;
|
|
179
|
+
const CHECKBOX_RE = /^\s*[-*]\s*\[([ xX~])\]\s+(.+?)\s*$/;
|
|
180
|
+
|
|
181
|
+
// One plan.observed (the honest denominator, v0.2 amendment 5) + one
|
|
182
|
+
// plan.ticked per [x] line. [ ] contributes to the denominator only.
|
|
183
|
+
// [~] means "written, not verified" in this house — it is NEVER migrated as
|
|
184
|
+
// done; only /morning or the user promotes it, and backfill does not get to
|
|
185
|
+
// promote what a human never verified.
|
|
186
|
+
function backfillDailyTasks(noteText, { noteDate, relPath }) {
|
|
187
|
+
const events = [];
|
|
188
|
+
const skipped = [];
|
|
189
|
+
const ts = tsFromDateKey(noteDate);
|
|
190
|
+
if (ts === null) {
|
|
191
|
+
return { events, skipped: [{ locator: relPath, reason: `invalid note date ${JSON.stringify(noteDate)}` }] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const lines = noteText.split("\n");
|
|
195
|
+
const headIdx = lines.findIndex((l) => /^#{1,4}\s*Tasks\b/i.test(l));
|
|
196
|
+
if (headIdx === -1) {
|
|
197
|
+
return { events, skipped: [{ locator: `${relPath}#tasks`, reason: "no Tasks section" }] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const taskIds = [];
|
|
201
|
+
const texts = [];
|
|
202
|
+
const ticks = [];
|
|
203
|
+
for (let i = headIdx + 1; i < lines.length; i++) {
|
|
204
|
+
if (/^#{1,4}\s/.test(lines[i])) break; // next heading ends the section
|
|
205
|
+
const m = lines[i].match(CHECKBOX_RE);
|
|
206
|
+
if (!m) continue;
|
|
207
|
+
const text = normalizeTask(m[2]);
|
|
208
|
+
const taskId = taskIdFor(noteDate, text);
|
|
209
|
+
if (taskIds.includes(taskId)) continue; // duplicate line, one denominator slot
|
|
210
|
+
taskIds.push(taskId);
|
|
211
|
+
texts.push(text);
|
|
212
|
+
if (m[1] === "x" || m[1] === "X") ticks.push({ taskId, task: text, line: i + 1 });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (taskIds.length === 0) {
|
|
216
|
+
return { events, skipped: [{ locator: `${relPath}#tasks`, reason: "Tasks section has no checkboxes" }] };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
events.push(
|
|
220
|
+
planObserved({ noteDate, taskIds, texts }, { ts, source: sourceFor(relPath, noteText, "tasks") })
|
|
221
|
+
);
|
|
222
|
+
for (const t of ticks) {
|
|
223
|
+
events.push(
|
|
224
|
+
planTicked(
|
|
225
|
+
{ taskId: t.taskId, task: t.task, note_date: noteDate, done: true },
|
|
226
|
+
{ ts, source: sourceFor(relPath, noteText, `tasks:line:${t.line}`) }
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return { events, skipped };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── whole-vault sweep ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
function collectDailyNotes(vaultRoot) {
|
|
236
|
+
const dir = path.join(vaultRoot, "02_Areas", "Engine", "Dailies");
|
|
237
|
+
const notes = [];
|
|
238
|
+
if (!fs.existsSync(dir)) return notes;
|
|
239
|
+
for (const month of fs.readdirSync(dir).sort()) {
|
|
240
|
+
const monthDir = path.join(dir, month);
|
|
241
|
+
let entries;
|
|
242
|
+
try {
|
|
243
|
+
entries = fs.readdirSync(monthDir);
|
|
244
|
+
} catch {
|
|
245
|
+
continue; // a stray file at the month level, not a folder
|
|
246
|
+
}
|
|
247
|
+
for (const name of entries.sort()) {
|
|
248
|
+
const m = name.match(NOTE_NAME_RE);
|
|
249
|
+
if (!m) continue;
|
|
250
|
+
notes.push({
|
|
251
|
+
noteDate: m[1],
|
|
252
|
+
absPath: path.join(monthDir, name),
|
|
253
|
+
relPath: path.join("02_Areas", "Engine", "Dailies", month, name),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return notes;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The full copy-first backfill: manifest overrides + every Daily's tasks,
|
|
261
|
+
// returned in a deterministic order (ts ascending, locator as tiebreak) so
|
|
262
|
+
// two runs over the same copy produce the same append sequence and the same
|
|
263
|
+
// dedupeKey set (ids are fresh UUIDs by design — identity is opaque).
|
|
264
|
+
function backfillVault(vaultRoot, { manifestPath } = {}) {
|
|
265
|
+
const events = [];
|
|
266
|
+
const skipped = [];
|
|
267
|
+
|
|
268
|
+
if (manifestPath && fs.existsSync(manifestPath)) {
|
|
269
|
+
const text = fs.readFileSync(manifestPath, "utf8");
|
|
270
|
+
const r = backfillOverrides(text, { manifestPath: path.basename(manifestPath) });
|
|
271
|
+
events.push(...r.events);
|
|
272
|
+
skipped.push(...r.skipped);
|
|
273
|
+
} else if (manifestPath) {
|
|
274
|
+
skipped.push({ locator: manifestPath, reason: "manifest file not found" });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const note of collectDailyNotes(vaultRoot)) {
|
|
278
|
+
const text = fs.readFileSync(note.absPath, "utf8");
|
|
279
|
+
const r = backfillDailyTasks(text, { noteDate: note.noteDate, relPath: note.relPath });
|
|
280
|
+
events.push(...r.events);
|
|
281
|
+
skipped.push(...r.skipped);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
events.sort((a, b) => a.ts - b.ts || String(a.source && a.source.locator).localeCompare(String(b.source && b.source.locator)) || a.type.localeCompare(b.type));
|
|
285
|
+
return { events, skipped };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
PARSER_VERSION,
|
|
290
|
+
tsFromDateKey,
|
|
291
|
+
tsFromLoose,
|
|
292
|
+
taskIdFor,
|
|
293
|
+
normalizeTask,
|
|
294
|
+
backfillOverrides,
|
|
295
|
+
backfillDailyTasks,
|
|
296
|
+
collectDailyNotes,
|
|
297
|
+
backfillVault,
|
|
298
|
+
};
|