@thehammer/danx-dashboard-mcp 0.1.304 → 0.1.306
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 +1 -1
- package/dist/compaction-control.js +145 -0
- package/dist/heartbeat-reporter.js +35 -11
- package/dist/index.js +2 -0
- package/dist/listen.js +23 -0
- package/dist/plan-events-relay.js +55 -1
- package/dist/session-connection.js +5 -2
- package/dist/session-stream.js +8 -0
- package/dist/urgent-records.js +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ The contract the plugin builds against:
|
|
|
75
75
|
- **The accepted restart gap.** Only an insert that commits more than the dashboard's 30 s overlap after the event it follows can be missed, and only by a restart resume (the dashboard's own limit); a running relay never misses one. A burst of more than 64 events inside the overlap can replay the oldest once (a duplicate, never a loss).
|
|
76
76
|
- **One plan.** `plan_id` is the plan the session is on. A plan different from the last call's drops the plan-scoped records buffered or held for the plan it left (dashboard events, the digest, the idle nudge), so a plan move starts clean. Session-scoped records (the pacing and auto-resume messages, which are never replayed, and the relay's own failure and revoked-key notices) are kept across a plan move and across the gap below. One wait is pending at a time: a newer call supersedes the pending one (which answers empty at once); an aborted request drops its waiter.
|
|
77
77
|
- **One heartbeat window.** `POST /api/plan-sessions/me/heartbeat` every 15 s while a call is pending or was made within the last 40 s; after that the stream is closed and heartbeats stop, so a dead or unloaded plugin module reads not-healthy (the dashboard's liveness window is 60 s). The plugin therefore waits 20 s per call and backs off at most 10 s between failed calls. Stats and the transcript title ride the heartbeat when `transcript_path` is given. The sequence survives the gap, so a cursor stays valid across it.
|
|
78
|
-
- **Failures inside the server.** A transient mint failure (network, timeout, 408, 429, 5xx) and a dropped stream back off 1 s doubling to 30 s (the one schedule, `listen.ts`); a lapsed ticket lease (15 min) re-mints and re-reads a failed plan inventory; three heartbeats that fail to land force a
|
|
78
|
+
- **Failures inside the server.** A transient mint failure (network, timeout, 408, 429, 5xx) and a dropped stream back off 1 s doubling to 30 s (the one schedule, `listen.ts`); a lapsed ticket lease (15 min) re-mints and re-reads a failed plan inventory; three heartbeats in a row that fail to land on an open stream force a fresh connection silently (a line on stderr, then an immediate heartbeat on the new stream), and the session is told only once the heartbeat has failed continuously for `HEARTBEAT_SUSTAINED_OUTAGE_MS` (5 minutes), in any connection state, once per outage, so a dashboard restart never wakes it; a revoked key tells the session once.
|
|
79
79
|
- **Nothing outlives the session.** The heartbeat interval, the backoff sleep and the digest hold are unref'd; `close()` (run when the transport closes and when stdin ends) aborts the stream and the mint and inventory requests and clears a pending call's wait timer, the one ref'd timer, which otherwise holds the process only until the call answers.
|
|
80
80
|
- **Credential and audience.** The server's own, from its boot-time `CLAUDE_CODE_SESSION_ID`: no connection record, fingerprint check or environment variable is involved. The tool is advertised only to a person's session holding `plans.view`, never to a dispatch. The stream's advisory frames (the idle nudge, DX-4340's pacing message, DX-4342's auto-resume message) arrive as records like any event, at once and never held for a digest. The digest (agent and machine events held up to 10 minutes) is one record, `[plan events] digest`, and a held id is never acknowledged before it is returned.
|
|
81
81
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4993 (PLAN-42 rule R-4) - the compaction control of an OPERATOR session, as this package relays it.
|
|
3
|
+
*
|
|
4
|
+
* 50 Arms decides when a session is asked to prepare for a compaction and when its agent has said ready (the dashboard's `compaction-handshake/`). The
|
|
5
|
+
* session's stream carries the decision as a `compaction` frame (`event: compaction`, data `{"control": <control> | null}`, level-triggered: the current
|
|
6
|
+
* control on connect and whenever it changes). The relay does two things with each frame:
|
|
7
|
+
*
|
|
8
|
+
* - the REQUEST (`control.ask`) reaches the agent as an urgent record, once per request (`ask.at` names it), so it arrives inside a running turn;
|
|
9
|
+
* - the rest is written to ONE local file the danxbot plugin's `PreCompact` gate reads: `~/.config/danxbot/compaction/<sessionId>.json`. The gate never
|
|
10
|
+
* asks the network: Claude Code proposes a compaction before every model request.
|
|
11
|
+
*
|
|
12
|
+
* The file is `{v: 1, sessionId, control: {floorTokens, passAtTokens, windowTokens, releaseUntilMs, releaseId} | null, askedAt: string | null}`. `sessionId` is the
|
|
13
|
+
* session the file was written for (the plugin refuses a file that names another); `windowTokens` is the context window the dashboard assumed for the session's
|
|
14
|
+
* model line (the plugin does not arm a session whose real window is smaller). `control: null` says the session is not under the handshake (the plugin disarms). `askedAt` is the relay's own memory of the last request it showed the agent, kept in the file so a restart
|
|
15
|
+
* of this server does not show it again; the plugin ignores it.
|
|
16
|
+
*
|
|
17
|
+
* The shapes below mirror the dashboard's `OperatorCompactionControl` (`src/dashboard/compaction-handshake/operator-session.ts`) and the plugin's gate
|
|
18
|
+
* (`danxbot/hooks/compaction/control.ts` in the plugin repo): this published package cannot import either. Each side tolerates the other's previous
|
|
19
|
+
* version by ignoring a frame or a file key it does not know and refusing nothing else (see the release order on DX-4993).
|
|
20
|
+
*/
|
|
21
|
+
import { mkdirSync, readFileSync, renameSync, utimesSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { prefixedLine } from "./one-line.js";
|
|
25
|
+
import { assertSessionIdSegment, pruneStaleSessionFiles } from "./session-connection.js";
|
|
26
|
+
/** The longest request line relayed whole (the pacing line's bound): the dashboard's request is a few hundred characters. */
|
|
27
|
+
export const COMPACTION_ASK_MAX_CODE_POINTS = 1000;
|
|
28
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
29
|
+
const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
30
|
+
/** Why a `compaction` frame's data cannot be read, or `null` when it can. */
|
|
31
|
+
export function invalidCompactionReason(value) {
|
|
32
|
+
if (!isRecord(value) || !("control" in value))
|
|
33
|
+
return "the frame has no control";
|
|
34
|
+
const c = value.control;
|
|
35
|
+
if (c === null)
|
|
36
|
+
return null;
|
|
37
|
+
if (!isRecord(c))
|
|
38
|
+
return "control is not an object or null";
|
|
39
|
+
if (c.v !== 1)
|
|
40
|
+
return `control version ${JSON.stringify(c.v)} is not 1`;
|
|
41
|
+
if (!isCount(c.floorTokens) || !isCount(c.passAtTokens) || !isCount(c.windowTokens))
|
|
42
|
+
return "floorTokens, passAtTokens and windowTokens must be whole numbers";
|
|
43
|
+
// DX-4993: the plugin refuses a control whose valve is not above its floor, and the dashboard never sends one; a frame that would become such a file is a fault line
|
|
44
|
+
if (c.passAtTokens <= c.floorTokens)
|
|
45
|
+
return "passAtTokens must be above floorTokens";
|
|
46
|
+
if (c.windowTokens <= c.passAtTokens)
|
|
47
|
+
return "windowTokens must be above passAtTokens";
|
|
48
|
+
if (c.ask !== null) {
|
|
49
|
+
if (!isRecord(c.ask) || !isCount(c.ask.seq) || typeof c.ask.at !== "string" || c.ask.at === "" || typeof c.ask.text !== "string" || c.ask.text.trim() === "") {
|
|
50
|
+
return "ask must be null or {seq, at, text}";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if ((c.releaseUntilMs === null) !== (c.releaseId === null))
|
|
54
|
+
return "releaseUntilMs and releaseId are both set or both null";
|
|
55
|
+
if (c.releaseUntilMs !== null && !(typeof c.releaseUntilMs === "number" && Number.isFinite(c.releaseUntilMs)))
|
|
56
|
+
return "releaseUntilMs must be a number or null";
|
|
57
|
+
if (c.releaseId !== null && (typeof c.releaseId !== "string" || c.releaseId === ""))
|
|
58
|
+
return "releaseId must be a string or null";
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** The control of a valid frame's data (`invalidCompactionReason` is `null`). */
|
|
62
|
+
export function controlOfFrame(value) {
|
|
63
|
+
const reason = invalidCompactionReason(value);
|
|
64
|
+
if (reason !== null)
|
|
65
|
+
throw new Error(`malformed compaction frame: ${reason}`);
|
|
66
|
+
return value.control;
|
|
67
|
+
}
|
|
68
|
+
/** The line the agent reads for a request: the dashboard's text on one line, prefixed like every stream line. */
|
|
69
|
+
export function formatCompactionAskLine(ask) {
|
|
70
|
+
return prefixedLine(ask.text, COMPACTION_ASK_MAX_CODE_POINTS);
|
|
71
|
+
}
|
|
72
|
+
/** The file of session `sessionId` for `control` (`null`: not under the handshake) after the request `askedAt` was shown last. */
|
|
73
|
+
export function fileOf(control, askedAt, sessionId) {
|
|
74
|
+
return {
|
|
75
|
+
v: 1,
|
|
76
|
+
sessionId,
|
|
77
|
+
control: control === null
|
|
78
|
+
? null
|
|
79
|
+
: { floorTokens: control.floorTokens, passAtTokens: control.passAtTokens, windowTokens: control.windowTokens, releaseUntilMs: control.releaseUntilMs, releaseId: control.releaseId },
|
|
80
|
+
askedAt,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function compactionDir(home = homedir()) {
|
|
84
|
+
return join(home, ".config", "danxbot", "compaction");
|
|
85
|
+
}
|
|
86
|
+
/** `~/.config/danxbot/compaction/<sessionId>.json`, next to the plugin's other per-session records. */
|
|
87
|
+
export function compactionFilePath(sessionId, home = homedir()) {
|
|
88
|
+
assertSessionIdSegment(sessionId);
|
|
89
|
+
return join(compactionDir(home), `${sessionId}.json`);
|
|
90
|
+
}
|
|
91
|
+
export function parseCompactionFile(text, where) {
|
|
92
|
+
const parsed = JSON.parse(text);
|
|
93
|
+
if (!isRecord(parsed) || parsed.v !== 1 || typeof parsed.sessionId !== "string" || !("control" in parsed) || (parsed.askedAt !== null && typeof parsed.askedAt !== "string")) {
|
|
94
|
+
throw new Error(`${where} is not a compaction file`);
|
|
95
|
+
}
|
|
96
|
+
const reason = parsed.control === null ? null : invalidCompactionReason({ control: { ...parsed.control, v: 1, ask: null } });
|
|
97
|
+
if (reason !== null)
|
|
98
|
+
throw new Error(`${where}: ${reason}`);
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The real store, under `home`. Every write also removes the files nothing has touched for a week (`pruneStaleSessionFiles`, like the session's connection
|
|
103
|
+
* record and key file). A live session's file is never among them: its relay touches the file at every heartbeat (`touch`), so only the files of sessions whose
|
|
104
|
+
* relay has been gone for a week are removed.
|
|
105
|
+
*/
|
|
106
|
+
export function compactionFilesIn(home = homedir(), now = Date.now) {
|
|
107
|
+
return {
|
|
108
|
+
read(sessionId) {
|
|
109
|
+
const path = compactionFilePath(sessionId, home);
|
|
110
|
+
let text;
|
|
111
|
+
try {
|
|
112
|
+
text = readFileSync(path, "utf-8");
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err.code === "ENOENT")
|
|
116
|
+
return null;
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
const file = parseCompactionFile(text, path);
|
|
120
|
+
if (file.sessionId !== sessionId)
|
|
121
|
+
throw new Error(`${path} was written for session ${JSON.stringify(file.sessionId)}`);
|
|
122
|
+
return file;
|
|
123
|
+
},
|
|
124
|
+
write(sessionId, file) {
|
|
125
|
+
const path = compactionFilePath(sessionId, home);
|
|
126
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
127
|
+
pruneStaleSessionFiles(dirname(path), now());
|
|
128
|
+
// A sibling then a rename: the plugin's gate reads this file on every compaction proposal and must never see half of one.
|
|
129
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
130
|
+
writeFileSync(temp, JSON.stringify(file), "utf-8");
|
|
131
|
+
renameSync(temp, path);
|
|
132
|
+
},
|
|
133
|
+
touch(sessionId) {
|
|
134
|
+
const at = new Date(now());
|
|
135
|
+
try {
|
|
136
|
+
utimesSync(compactionFilePath(sessionId, home), at, at);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// a session with no file has nothing to keep; any other failure is the caller's to report
|
|
140
|
+
if (err.code !== "ENOENT")
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { keyRevokedHalt } from "./key-revoked-halt.js";
|
|
11
11
|
import { oneLine } from "./one-line.js";
|
|
12
12
|
import { DIGEST_PREFIX } from "./digest-hold.js";
|
|
13
|
-
import {
|
|
13
|
+
import { HEARTBEAT_SILENT_FAILURE_THRESHOLD, HEARTBEAT_SUSTAINED_OUTAGE_MS, sendHeartbeat, } from "./session-stream.js";
|
|
14
14
|
import { OperatorSessionStats } from "./session-stats.js";
|
|
15
15
|
import { TranscriptTitleReader } from "./session-title.js";
|
|
16
16
|
export class HeartbeatReporter {
|
|
@@ -23,8 +23,11 @@ export class HeartbeatReporter {
|
|
|
23
23
|
// since an older dashboard refuses the `title` key and a refused heartbeat is a dead listener.
|
|
24
24
|
dashboardTitle;
|
|
25
25
|
lastTitleError = null;
|
|
26
|
-
// DX-3099 - consecutive heartbeats that failed to LAND
|
|
26
|
+
// DX-3099 - consecutive heartbeats that failed to LAND on one open stream (DX-5088: per stream, reset whenever the state leaves `streaming`).
|
|
27
27
|
failureStreak = 0;
|
|
28
|
+
// DX-5088 - when the heartbeat first failed to land in the current outage (across every reconnect); null while the last one landed.
|
|
29
|
+
outageSince = null;
|
|
30
|
+
outageAnnounced = false;
|
|
28
31
|
// DX-4418 - the stop notice for a revoked key goes out once; every later beat is refused the same way.
|
|
29
32
|
revocationAnnounced = false;
|
|
30
33
|
constructor(deps, transcriptPath) {
|
|
@@ -90,10 +93,34 @@ export class HeartbeatReporter {
|
|
|
90
93
|
return answer;
|
|
91
94
|
})
|
|
92
95
|
.then((landed) => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
const now = this.deps.now();
|
|
97
|
+
if (landed) {
|
|
98
|
+
this.failureStreak = 0;
|
|
99
|
+
this.outageSince = null;
|
|
100
|
+
this.outageAnnounced = false;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.outageSince ??= now;
|
|
104
|
+
// DX-5088 - the model hears of an outage only once it is sustained, in any connection state (a dashboard that stays down keeps
|
|
105
|
+
// the stream reconnecting, and that is exactly the outage worth saying); a blip such as a ~35 s deploy restart is stderr only.
|
|
106
|
+
const downMs = now - this.outageSince;
|
|
107
|
+
if (downMs >= HEARTBEAT_SUSTAINED_OUTAGE_MS && !this.outageAnnounced) {
|
|
108
|
+
this.outageAnnounced = true;
|
|
109
|
+
const minutes = Math.round(downMs / 60_000);
|
|
110
|
+
const connection = this.deps.connection();
|
|
111
|
+
// DX-5088: the wording follows the state. A stopped relay (a terminal outcome) does not reconnect by itself, so "keeps reconnecting" would be false.
|
|
112
|
+
const wording = connection.state === "stopped"
|
|
113
|
+
? `this session's listener has stopped${connection.reason === undefined ? "" : ` (${connection.reason})`} and does not reconnect by itself, ` +
|
|
114
|
+
`so the dashboard has heard nothing from it for ${minutes} minutes. The next plan_events_wait call says why it stopped and what to do; ` +
|
|
115
|
+
`a person can also restart the session.`
|
|
116
|
+
: `this session's listener has not reached the dashboard for ${minutes} minutes (its heartbeats keep failing). ` +
|
|
117
|
+
`It keeps reconnecting on its own, and nothing is needed from you.`;
|
|
118
|
+
this.deps.announce(oneLine(`${DIGEST_PREFIX} ${wording}`), "relay_notice");
|
|
119
|
+
}
|
|
120
|
+
// DX-3099 - the self-heal counts only while a stream is open: that is the desync the card found (the client's stream reads
|
|
121
|
+
// open; the dashboard has nothing recent for it). DX-5088: while it is reconnecting the reconnect already owns recovery, and
|
|
122
|
+
// aborting it would restart the backoff, so a failure then neither counts nor forces anything.
|
|
123
|
+
if (!this.deps.streaming()) {
|
|
97
124
|
this.failureStreak = 0;
|
|
98
125
|
return;
|
|
99
126
|
}
|
|
@@ -101,15 +128,12 @@ export class HeartbeatReporter {
|
|
|
101
128
|
if (this.failureStreak < HEARTBEAT_SILENT_FAILURE_THRESHOLD)
|
|
102
129
|
return;
|
|
103
130
|
this.failureStreak = 0;
|
|
104
|
-
|
|
105
|
-
this.deps.announce(oneLine(`${DIGEST_PREFIX} this session's listener stopped reaching the dashboard for ` +
|
|
106
|
-
`${Math.round((HEARTBEAT_SILENT_FAILURE_THRESHOLD * HEARTBEAT_INTERVAL_MS) / 1000)}s while it still ` +
|
|
107
|
-
`believed it was streaming — forcing a fresh connection now.`), "relay_notice");
|
|
131
|
+
process.stderr.write(`plan_events_wait: ${HEARTBEAT_SILENT_FAILURE_THRESHOLD} heartbeats in a row did not land on a stream that reads open; forcing a fresh connection\n`);
|
|
108
132
|
// End the CURRENT connection now, rather than wait on the stream reader's own read-idle timeout (which this exact
|
|
109
133
|
// failure mode can outlive indefinitely: the connection reads as established and keeps producing keep-alive bytes,
|
|
110
134
|
// only the outbound heartbeat silently never lands). The new attempt reconnects with the SAME ticket; the
|
|
111
135
|
// dashboard's one-open-stream-per-ticket rule then ends the old, superseded entry, releasing the exclusive claim
|
|
112
|
-
// as a side effect of THIS reconnect, never an external watchdog.
|
|
136
|
+
// as a side effect of THIS reconnect, never an external watchdog. The new stream's `streaming` sends a heartbeat at once.
|
|
113
137
|
this.deps.forceReconnect();
|
|
114
138
|
})
|
|
115
139
|
.catch((err) => {
|
package/dist/index.js
CHANGED
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
* 5xx and network failures throw — never silently swallowed.
|
|
83
83
|
*/
|
|
84
84
|
import { isEntrypointModule } from "./entrypoint.js";
|
|
85
|
+
import { compactionFilesIn } from "./compaction-control.js";
|
|
85
86
|
import { PlanEventRelay, WAIT_MAX_MS, WAIT_MIN_MS } from "./plan-events-relay.js";
|
|
86
87
|
import { READ_IDLE_TIMEOUT_MS } from "./listen.js";
|
|
87
88
|
import { REQUEST_TIMEOUT_MS, realScheduleInterval, realSleep } from "./session-stream.js";
|
|
@@ -256,6 +257,7 @@ function boot() {
|
|
|
256
257
|
readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
|
|
257
258
|
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
|
258
259
|
scheduleInterval: realScheduleInterval,
|
|
260
|
+
compactionFiles: compactionFilesIn(),
|
|
259
261
|
});
|
|
260
262
|
permissionWatch = new PermissionWatch({
|
|
261
263
|
dashboardUrl,
|
package/dist/listen.js
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* (`src/issues/db/issue-activity.ts`); this published package cannot import
|
|
42
42
|
* danxbot source.
|
|
43
43
|
*/
|
|
44
|
+
import { controlOfFrame, invalidCompactionReason } from "./compaction-control.js";
|
|
44
45
|
import { LINE_PREFIX, oneLine } from "./one-line.js";
|
|
45
46
|
import { pacingRelayLine } from "./pacing-relay.js";
|
|
46
47
|
import { isUrgent } from "./urgent-records.js";
|
|
@@ -524,6 +525,22 @@ export async function runListener(options, deps) {
|
|
|
524
525
|
remember(id);
|
|
525
526
|
return null;
|
|
526
527
|
};
|
|
528
|
+
const emitCompaction = (message) => {
|
|
529
|
+
let parsed;
|
|
530
|
+
let invalid;
|
|
531
|
+
try {
|
|
532
|
+
parsed = JSON.parse(message.data);
|
|
533
|
+
invalid = invalidCompactionReason(parsed);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
invalid = `not JSON: ${err.message}`;
|
|
537
|
+
}
|
|
538
|
+
if (invalid === null)
|
|
539
|
+
deps.write({ type: "compaction", control: controlOfFrame(parsed) });
|
|
540
|
+
else
|
|
541
|
+
deps.write({ type: "event", id: null, text: `${LINE_PREFIX} could not read compaction control (${invalid}): ${oneLine(message.data, 300)}`, origin: null, urgent: isUrgent({ source: "fault" }), scope: "session" });
|
|
542
|
+
return null;
|
|
543
|
+
};
|
|
527
544
|
const handle = (message) => {
|
|
528
545
|
if (message.event === "end") {
|
|
529
546
|
// DX-2920 (round 3) — a JSON.parse failure here falls through to the
|
|
@@ -551,6 +568,9 @@ export async function runListener(options, deps) {
|
|
|
551
568
|
// DX-4342 - the auto-resume's message once the limit that paused or stopped this session has reset: advisory like a pacing message.
|
|
552
569
|
if (message.event === "resume")
|
|
553
570
|
return emitAdvisory(message, "resume message", invalidResumeReason, (p) => formatResumeLine(p), () => ({ source: "resume" }), "session");
|
|
571
|
+
// DX-4993 - the compaction control: level-triggered, no id, no replay. A frame that cannot be read is a fault line (never silence), and the next frame still lands.
|
|
572
|
+
if (message.event === "compaction")
|
|
573
|
+
return emitCompaction(message);
|
|
554
574
|
if (message.event !== "activity")
|
|
555
575
|
return null;
|
|
556
576
|
// `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
|
|
@@ -673,6 +693,9 @@ export async function runListener(options, deps) {
|
|
|
673
693
|
if (outcome.kind === "refused") {
|
|
674
694
|
return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
|
|
675
695
|
}
|
|
696
|
+
// DX-5088: the stream is down from here until the next attempt opens; reporting `streaming` through the backoff made the heartbeat watchdog
|
|
697
|
+
// call a dashboard outage "a stream that looks open" and abort the reconnect it was waiting on.
|
|
698
|
+
deps.onState?.("reconnecting");
|
|
676
699
|
if (outcome.detail !== undefined)
|
|
677
700
|
lastDropDetail = outcome.detail;
|
|
678
701
|
if (outcome.healthy) {
|
|
@@ -53,11 +53,13 @@
|
|
|
53
53
|
* mint / inventory requests are aborted by `close()`, and `close()` also clears a pending call's wait timer (the one ref'd timer,
|
|
54
54
|
* held only until the call answers). The server calls `close()` when its transport closes and when its stdin ends (`shutdown.ts`).
|
|
55
55
|
*/
|
|
56
|
+
import { fileOf, formatCompactionAskLine } from "./compaction-control.js";
|
|
56
57
|
import { DigestHold } from "./digest-hold.js";
|
|
57
58
|
import { HeartbeatReporter } from "./heartbeat-reporter.js";
|
|
58
59
|
import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
|
|
59
60
|
import { fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
|
|
60
61
|
import { fixForStop } from "./stop-fixes.js";
|
|
62
|
+
import { LINE_PREFIX } from "./one-line.js";
|
|
61
63
|
import { isUrgent } from "./urgent-records.js";
|
|
62
64
|
/** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
|
|
63
65
|
export const WAIT_MIN_MS = 1_000;
|
|
@@ -275,6 +277,7 @@ export class PlanEventRelay {
|
|
|
275
277
|
},
|
|
276
278
|
http: this.deps,
|
|
277
279
|
makeStatsWatcher: this.deps.makeStatsWatcher,
|
|
280
|
+
now: this.deps.now,
|
|
278
281
|
connection: () => heartbeatConnection(this.connectionState, this.stoppedReason, this.lastInventoryError),
|
|
279
282
|
streaming: () => this.connectionState === "streaming",
|
|
280
283
|
// `origin: null` skips the agent/machine hold: the notice reaches the session at once. It is about the session, not a plan.
|
|
@@ -295,10 +298,22 @@ export class PlanEventRelay {
|
|
|
295
298
|
this.suspend();
|
|
296
299
|
return;
|
|
297
300
|
}
|
|
298
|
-
|
|
301
|
+
const auth = this.deps.auth();
|
|
302
|
+
if (auth === null)
|
|
299
303
|
return;
|
|
304
|
+
this.touchCompactionFile(auth.sessionId);
|
|
300
305
|
this.reporter?.tick();
|
|
301
306
|
}
|
|
307
|
+
/** DX-4993: a live relay keeps its session's compaction file out of the weekly pruning (`CompactionFiles.touch`); a failure is reported to stderr, never ends the heartbeat. */
|
|
308
|
+
touchCompactionFile(sessionId) {
|
|
309
|
+
try {
|
|
310
|
+
this.deps.compactionFiles.touch(sessionId);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
process.stderr.write(`plan_events_wait: the compaction control file could not be kept alive: ${err instanceof Error ? err.message : String(err)}
|
|
314
|
+
`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
302
317
|
/**
|
|
303
318
|
* Let go of everything the relay holds for a caller: the stream, the heartbeat, the digest, and the plan-scoped records.
|
|
304
319
|
* What was held for a digest was never marked delivered, so the dashboard's replay (after the caller's cursor) returns it to
|
|
@@ -338,6 +353,38 @@ export class PlanEventRelay {
|
|
|
338
353
|
this.terminal(run, "relay_failed", message);
|
|
339
354
|
});
|
|
340
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* DX-4993 - the session's compaction control arrived: show the agent a request it has not been shown, and write the file the plugin's gate reads.
|
|
358
|
+
* A control with a new request (`ask.at`) is shown once: the file remembers which, so a reconnect, a restart of this server or a repeat of the same
|
|
359
|
+
* frame shows nothing twice. A file that cannot be read or written is a line to the agent (a fault: the gate cannot work), never silence.
|
|
360
|
+
*/
|
|
361
|
+
applyCompaction(control) {
|
|
362
|
+
const auth = this.deps.auth();
|
|
363
|
+
if (auth === null)
|
|
364
|
+
return;
|
|
365
|
+
// DX-4993: a file that cannot be read is no memory of what was shown, never a reason not to write: the write below replaces it (otherwise the same fault repeats
|
|
366
|
+
// at every frame and the plugin's gate never gets a readable control). The agent is told once per frame that it happened.
|
|
367
|
+
let askedAt = null;
|
|
368
|
+
try {
|
|
369
|
+
askedAt = this.deps.compactionFiles.read(auth.sessionId)?.askedAt ?? null;
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
373
|
+
this.digest.route({ type: "event", id: null, text: `${LINE_PREFIX} the compaction control file of this session could not be read (${detail}); it is replaced`, origin: null, urgent: isUrgent({ source: "fault" }), scope: "session" });
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const ask = control?.ask ?? null;
|
|
377
|
+
const show = ask !== null && ask.at !== askedAt;
|
|
378
|
+
this.deps.compactionFiles.write(auth.sessionId, fileOf(control, ask === null ? null : show ? ask.at : askedAt, auth.sessionId));
|
|
379
|
+
if (show) {
|
|
380
|
+
this.digest.route({ type: "event", id: null, text: formatCompactionAskLine(ask), origin: null, urgent: isUrgent({ source: "compaction_ask" }), scope: "session" });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
385
|
+
this.digest.route({ type: "event", id: null, text: `${LINE_PREFIX} the compaction control could not be kept for this session (${detail}), so its compaction gate cannot work`, origin: null, urgent: isUrgent({ source: "fault" }), scope: "session" });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
341
388
|
current(run) {
|
|
342
389
|
return this.run === run && !run.abort.signal.aborted;
|
|
343
390
|
}
|
|
@@ -415,12 +462,19 @@ export class PlanEventRelay {
|
|
|
415
462
|
return;
|
|
416
463
|
}
|
|
417
464
|
attempt.emitted = true;
|
|
465
|
+
if (output.type === "compaction") {
|
|
466
|
+
this.applyCompaction(output.control);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
418
469
|
this.digest.route(output);
|
|
419
470
|
},
|
|
420
471
|
onState: (state) => {
|
|
421
472
|
if (!this.current(run))
|
|
422
473
|
return;
|
|
423
474
|
this.connectionState = state;
|
|
475
|
+
// DX-5088: the heartbeat failure streak is per stream; a drop ends it, so a quick re-open never inherits the old count.
|
|
476
|
+
if (state !== "streaming")
|
|
477
|
+
this.reporter?.resetFailureStreak();
|
|
424
478
|
// Report `streaming` now, not up to a beat later: the session reads healthy as soon as it is.
|
|
425
479
|
if (state === "streaming" && this.deps.auth() !== null)
|
|
426
480
|
this.reporter?.tick();
|
|
@@ -67,7 +67,10 @@ export function sessionConnectionPath(sessionId, home = homedir()) {
|
|
|
67
67
|
assertSessionIdSegment(sessionId);
|
|
68
68
|
return join(sessionConnectionDir(home), `${sessionId}.json`);
|
|
69
69
|
}
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Remove the per-session `.json` files in `dir` that nothing has touched for `STALE_RECORD_MS`, and the `.tmp` files a failed
|
|
72
|
+
* write-then-rename left beside them (DX-4993).
|
|
73
|
+
*/
|
|
71
74
|
export function pruneStaleSessionFiles(dir, now) {
|
|
72
75
|
let names;
|
|
73
76
|
try {
|
|
@@ -77,7 +80,7 @@ export function pruneStaleSessionFiles(dir, now) {
|
|
|
77
80
|
return; // nothing written yet
|
|
78
81
|
}
|
|
79
82
|
for (const name of names) {
|
|
80
|
-
if (!name.endsWith(".json"))
|
|
83
|
+
if (!name.endsWith(".json") && !name.endsWith(".tmp"))
|
|
81
84
|
continue;
|
|
82
85
|
const file = join(dir, name);
|
|
83
86
|
try {
|
package/dist/session-stream.js
CHANGED
|
@@ -57,6 +57,14 @@ export const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
|
57
57
|
* of the obvious wrong fixes this constant is not).
|
|
58
58
|
*/
|
|
59
59
|
export const HEARTBEAT_SILENT_FAILURE_THRESHOLD = 3;
|
|
60
|
+
/**
|
|
61
|
+
* DX-5088 - how long the heartbeat must keep failing to land (counted from the first failure, across every reconnect, until one
|
|
62
|
+
* lands) before the MODEL is told. 5 minutes: a production deploy replaces the single dashboard container, an outage the
|
|
63
|
+
* investigation measured at about 35 s (DX-5085 comment 14821), and the lock-convoy stalls around it ran 8-27 s more; 5 minutes is
|
|
64
|
+
* roughly 8x the restart, so a deploy never wakes a session, while an outage that outlasts it is one the session's person should hear
|
|
65
|
+
* about (the dashboard has long read the session `unattached`). Below it the self-heal is silent and logged to stderr.
|
|
66
|
+
*/
|
|
67
|
+
export const HEARTBEAT_SUSTAINED_OUTAGE_MS = 5 * 60_000;
|
|
60
68
|
const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
|
|
61
69
|
/**
|
|
62
70
|
* One authenticated request, with a timeout, classifying the failures that are
|
package/dist/urgent-records.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* creates a record names its source and calls `isUrgent`; none writes an `urgent` literal of its own. The flag is a required
|
|
8
8
|
* boolean on `ListenEvent`, so a site that forgets it fails `make typecheck`, and no reader defaults a missing one.
|
|
9
9
|
*
|
|
10
|
-
* Urgent: a critical usage-pacing stop (the account is about to hit its limit and the session must wind down now),
|
|
11
|
-
* notice that this session's key was revoked (it can no longer call the dashboard). Everything else is not: activity events
|
|
10
|
+
* Urgent: a critical usage-pacing stop (the account is about to hit its limit and the session must wind down now), the
|
|
11
|
+
* notice that this session's key was revoked (it can no longer call the dashboard), and 50 Arms' request to prepare for a compaction. Everything else is not: activity events
|
|
12
12
|
* (comments, answers, problems, blocks, design and pull-request outcomes), the idle nudge, the auto-resume message, a digest,
|
|
13
13
|
* the relay's own failure notice, and any line that reports an unreadable event or frame (a fault line).
|
|
14
14
|
*
|
|
@@ -23,6 +23,9 @@ export function isUrgent(record) {
|
|
|
23
23
|
return record.level === "critical";
|
|
24
24
|
case "key_revoked":
|
|
25
25
|
return true;
|
|
26
|
+
// DX-4993: 50 Arms' request to prepare for a compaction must reach the agent inside a running turn, not after it: the compaction waits for the agent's ready.
|
|
27
|
+
case "compaction_ask":
|
|
28
|
+
return true;
|
|
26
29
|
case "activity":
|
|
27
30
|
case "nudge":
|
|
28
31
|
case "resume":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.306",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|