mixdog 0.9.22 → 0.9.24
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 -4
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +150 -6
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ mixdog
|
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
First run walks you through onboarding: provider auth, model pick, and
|
|
26
|
-
workflow setup.
|
|
26
|
+
workflow setup.
|
|
27
27
|
|
|
28
28
|
## Terminal-Bench 2.1 — 89.9% (self-reported)
|
|
29
29
|
|
|
@@ -117,9 +117,6 @@ mixdog --readonly
|
|
|
117
117
|
|
|
118
118
|
# Enable remote/channel mode for this session
|
|
119
119
|
mixdog --remote
|
|
120
|
-
|
|
121
|
-
# Re-run first-run setup
|
|
122
|
-
mixdog --onboarding
|
|
123
120
|
```
|
|
124
121
|
|
|
125
122
|
Headless role mode is also supported:
|
package/package.json
CHANGED
package/scripts/boot-smoke.mjs
CHANGED
|
@@ -64,7 +64,7 @@ const rows = [
|
|
|
64
64
|
try {
|
|
65
65
|
const status = runtime.toolsStatus();
|
|
66
66
|
const active = new Set(status.activeTools || []);
|
|
67
|
-
for (const name of ['read','code_graph','grep','find','glob','list','apply_patch','explore','agent','shell','task','cwd','recall','search','web_fetch','Skill','
|
|
67
|
+
for (const name of ['read','code_graph','grep','find','glob','list','apply_patch','explore','agent','shell','task','cwd','recall','search','web_fetch','Skill','load_tool']) {
|
|
68
68
|
if (!active.has(name)) throw new Error('missing ' + name + ' in ' + [...active].join(','));
|
|
69
69
|
}
|
|
70
70
|
for (const name of ['bash']) {
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// Scripted two-client simulation for the machine-global channels daemon
|
|
2
|
+
// transport (no Discord token needed — the channels runtime is stubbed).
|
|
3
|
+
//
|
|
4
|
+
// Verifies the transport + attach client contracts that later slices depend on:
|
|
5
|
+
// 1. two TUIs attach to ONE daemon and each get an independent SSE stream;
|
|
6
|
+
// 2. tool calls round-trip per client and carry the caller's leadPid;
|
|
7
|
+
// 3. notifies are TARGETED (never broadcast) to the routing-pointer client;
|
|
8
|
+
// 4. a bind-intent call (rebind_current_transcript) moves the pointer;
|
|
9
|
+
// 5. once every client deregisters, the daemon self-shutdown fires.
|
|
10
|
+
//
|
|
11
|
+
// Run: node scripts/channel-daemon-smoke.mjs
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
19
|
+
import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
|
|
20
|
+
import { createParentBridge, setChannelNotifySink } from '../src/runtime/channels/lib/parent-bridge.mjs';
|
|
21
|
+
|
|
22
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
|
|
24
|
+
|
|
25
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
const waitFor = async (fn, ms = 1500) => {
|
|
27
|
+
const end = Date.now() + ms;
|
|
28
|
+
while (Date.now() < end) { if (fn()) return true; await delay(20); }
|
|
29
|
+
return fn();
|
|
30
|
+
};
|
|
31
|
+
let failures = 0;
|
|
32
|
+
function check(label, cond) {
|
|
33
|
+
const ok = !!cond;
|
|
34
|
+
if (!ok) failures++;
|
|
35
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-daemon-smoke-'));
|
|
40
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
41
|
+
let clientsEmptyFired = false;
|
|
42
|
+
let sideEffects = 0; // counts non-idempotent 'reply' dispatches
|
|
43
|
+
|
|
44
|
+
// Stub channels runtime: echoes the call + the caller identity the transport
|
|
45
|
+
// resolved, so we can assert per-client leadPid threading.
|
|
46
|
+
const handleCall = async (name, args, ctx) => {
|
|
47
|
+
if (name === 'reply') { sideEffects++; await delay(40); } // simulate a side effect
|
|
48
|
+
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const transport = createChannelDaemonTransport({
|
|
52
|
+
handleCall,
|
|
53
|
+
discoveryPath,
|
|
54
|
+
clientGraceMs: 250,
|
|
55
|
+
sweepMs: 1000,
|
|
56
|
+
onClientsEmpty: () => { clientsEmptyFired = true; },
|
|
57
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[daemon] ${m}`),
|
|
58
|
+
});
|
|
59
|
+
const { port, token } = await transport.start();
|
|
60
|
+
const discovery = { port, token, pid: process.pid };
|
|
61
|
+
|
|
62
|
+
const notA = [];
|
|
63
|
+
const notB = [];
|
|
64
|
+
const clientA = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
65
|
+
const clientB = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
66
|
+
await delay(150); // let both SSE streams attach
|
|
67
|
+
|
|
68
|
+
check('two clients registered', transport._clientsForTest.size === 2);
|
|
69
|
+
|
|
70
|
+
// (2) calls round-trip per client.
|
|
71
|
+
const rA = await clientA.call('reload_config', { k: 1 });
|
|
72
|
+
check('client A call round-trips', rA?.ok === true && rA?.name === 'reload_config');
|
|
73
|
+
const rB = await clientB.call('fetch', { q: 2 });
|
|
74
|
+
check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
|
|
75
|
+
|
|
76
|
+
// (3) LAST-WINS register: the NEWEST registrant (B) steals the ownership seat,
|
|
77
|
+
// so an inbound notify targets B — NOT the first registrant. (Under the old
|
|
78
|
+
// first-wins model this went to A.)
|
|
79
|
+
transport.notify('notifications/claude/channel', { content: 'to-B' });
|
|
80
|
+
await delay(120);
|
|
81
|
+
check('notify #1 targets newest registrant (B) only',
|
|
82
|
+
notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 0);
|
|
83
|
+
|
|
84
|
+
// (4) bind-intent call moves the pointer back to A.
|
|
85
|
+
await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' });
|
|
86
|
+
transport.notify('notifications/claude/channel', { content: 'to-A' });
|
|
87
|
+
await delay(120);
|
|
88
|
+
check('notify #2 delivered to A only after rebind',
|
|
89
|
+
notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 1);
|
|
90
|
+
|
|
91
|
+
// (fix 1) idempotent replay: two /call with the SAME callId (a retried
|
|
92
|
+
// transport failure) must run the non-idempotent side-effect exactly once.
|
|
93
|
+
const dupId = 'dup-call-1';
|
|
94
|
+
const [d1, d2] = await Promise.all([
|
|
95
|
+
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
96
|
+
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
97
|
+
]);
|
|
98
|
+
check('idempotent replay: same callId → exactly one side-effect',
|
|
99
|
+
sideEffects === 1 && d1?.ok === true && d2?.ok === true);
|
|
100
|
+
|
|
101
|
+
// (targeted) the 'acquired' badge goes ONLY to the current owner (pointer=A
|
|
102
|
+
// after the rebind), never broadcast — a displaced/non-owner TUI (B) must not
|
|
103
|
+
// light its remote badge. Still sticky for a late owner attach (below).
|
|
104
|
+
const remoteBefore = { A: notA.length, B: notB.length };
|
|
105
|
+
transport.notify('notifications/mixdog/remote', { state: 'acquired' });
|
|
106
|
+
await delay(120);
|
|
107
|
+
const gotA = notA.slice(remoteBefore.A).filter((m) => m?.method === 'notifications/mixdog/remote');
|
|
108
|
+
const gotB = notB.slice(remoteBefore.B).filter((m) => m?.method === 'notifications/mixdog/remote');
|
|
109
|
+
check('remote-state acquired targets owner (A) only, not broadcast',
|
|
110
|
+
gotA.length === 1 && gotB.length === 0);
|
|
111
|
+
|
|
112
|
+
// A late client that registers becomes the NEWEST owner (steals the pointer),
|
|
113
|
+
// so on attach it replays the sticky 'acquired' badge for itself.
|
|
114
|
+
const notC = [];
|
|
115
|
+
const clientC = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'C', onNotify: (m) => notC.push(m) });
|
|
116
|
+
await waitFor(() => notC.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
|
|
117
|
+
check('late owner receives replayed remote-state on attach',
|
|
118
|
+
notC.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
|
|
119
|
+
await clientC.close();
|
|
120
|
+
|
|
121
|
+
// (replay) 'superseded' CLEARS the sticky: a client attaching after it must
|
|
122
|
+
// receive NO remote-state replay (else it would wrongly stop a fresh client).
|
|
123
|
+
transport.notify('notifications/mixdog/remote', { state: 'superseded' });
|
|
124
|
+
await delay(60);
|
|
125
|
+
const notD = [];
|
|
126
|
+
const clientD = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'D', onNotify: (m) => notD.push(m) });
|
|
127
|
+
await delay(150);
|
|
128
|
+
check('attach after superseded receives no remote-state replay',
|
|
129
|
+
notD.filter((m) => m?.method === 'notifications/mixdog/remote').length === 0);
|
|
130
|
+
await clientD.close();
|
|
131
|
+
|
|
132
|
+
// (sink) daemon-mode wiring: owned-runtime routes remote-state through the
|
|
133
|
+
// parent bridge's sendNotifyToParent (NOT raw process.send). The daemon
|
|
134
|
+
// installs a sink -> transport.notify (channel-daemon.mjs:106). Assert an
|
|
135
|
+
// 'acquired' emitted via that sink reaches the transport and replays to a
|
|
136
|
+
// late client — the exact daemon-mode path a raw process.send would drop.
|
|
137
|
+
const { sendNotifyToParent } = createParentBridge({ getInstanceId: () => 'smoke' });
|
|
138
|
+
setChannelNotifySink((method, params) => transport.notify(method, params));
|
|
139
|
+
sendNotifyToParent('notifications/mixdog/remote', { state: 'acquired' });
|
|
140
|
+
await delay(60);
|
|
141
|
+
const notE = [];
|
|
142
|
+
const clientE = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'E', onNotify: (m) => notE.push(m) });
|
|
143
|
+
await waitFor(() => notE.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
|
|
144
|
+
check('daemon-mode acquire via notify sink reaches transport + replays',
|
|
145
|
+
notE.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
|
|
146
|
+
await clientE.close();
|
|
147
|
+
setChannelNotifySink(null);
|
|
148
|
+
|
|
149
|
+
// (5) deregister both → self-shutdown fires after the client grace window.
|
|
150
|
+
await clientA.close();
|
|
151
|
+
await clientB.close();
|
|
152
|
+
await delay(600);
|
|
153
|
+
check('daemon self-shutdown fired after last client left', clientsEmptyFired === true);
|
|
154
|
+
|
|
155
|
+
await transport.stop();
|
|
156
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
157
|
+
|
|
158
|
+
await flipTest();
|
|
159
|
+
await pointerStealTest();
|
|
160
|
+
await reattachBufferTest();
|
|
161
|
+
await pointerFailoverTest();
|
|
162
|
+
await pointerReconnectFollowsTest();
|
|
163
|
+
|
|
164
|
+
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
|
165
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Flip coverage: two TUIs attach to ONE daemon via the REAL channel-worker.mjs
|
|
169
|
+
// spawn-or-attach path (against the stub daemon entry — no Discord token). The
|
|
170
|
+
// first worker spawns the daemon; the second attaches to it.
|
|
171
|
+
async function flipTest() {
|
|
172
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-flip-smoke-'));
|
|
173
|
+
// channel-worker.mjs resolves DAEMON_ENTRY + runtimeRoot at import/call time,
|
|
174
|
+
// so the env must be set BEFORE importing it.
|
|
175
|
+
process.env.MIXDOG_RUNTIME_ROOT = tmp;
|
|
176
|
+
process.env.MIXDOG_DATA_DIR = tmp;
|
|
177
|
+
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
|
|
178
|
+
const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
|
|
179
|
+
const { readDaemonDiscovery } = await import('../src/standalone/channel-daemon-client.mjs');
|
|
180
|
+
const discFile = path.join(tmp, 'channel-daemon.json');
|
|
181
|
+
|
|
182
|
+
const notA = [];
|
|
183
|
+
const notB = [];
|
|
184
|
+
const mk = (onNotify) => createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: tmp, dataDir: tmp, cwd: tmp, onNotify });
|
|
185
|
+
const wA = mk((m) => notA.push(m));
|
|
186
|
+
const wB = mk((m) => notB.push(m));
|
|
187
|
+
|
|
188
|
+
await wA.start(); // spawns the (stub) daemon then attaches
|
|
189
|
+
await wB.start(); // attaches to the SAME daemon (no second spawn)
|
|
190
|
+
check('flip: worker A call round-trips', (await wA.execute('reply', { x: 1 }))?.ok === true);
|
|
191
|
+
check('flip: worker B call round-trips', (await wB.execute('reply', { y: 2 }))?.ok === true);
|
|
192
|
+
|
|
193
|
+
// B claims the transcript → routing pointer moves to B; a notify emitted by
|
|
194
|
+
// the daemon (on A's 'fetch') must reach B only, never both TUIs.
|
|
195
|
+
await wB.execute('rebind_current_transcript', { transcriptPath: path.join(tmp, 'b.jsonl') });
|
|
196
|
+
await wA.execute('fetch', { probe: true });
|
|
197
|
+
await waitFor(() => notB.some((m) => m?.params?.content === 'ping-from-stub'), 1500);
|
|
198
|
+
check('flip: notify targets the pointer TUI (B) only',
|
|
199
|
+
notB.some((m) => m?.params?.content === 'ping-from-stub') &&
|
|
200
|
+
!notA.some((m) => m?.params?.content === 'ping-from-stub'));
|
|
201
|
+
|
|
202
|
+
// (fix 1) daemon death mid-session: SIGKILL it, then the surviving client's
|
|
203
|
+
// next call must transparently respawn + re-attach (bounded retry inside
|
|
204
|
+
// execute) — no TUI process restart.
|
|
205
|
+
const beforeKill = readDaemonDiscovery(discFile);
|
|
206
|
+
if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
|
|
207
|
+
await delay(400);
|
|
208
|
+
let recovered = false;
|
|
209
|
+
try { recovered = (await wA.execute('reply', { after: 'kill' }))?.ok === true; } catch {}
|
|
210
|
+
const afterKill = readDaemonDiscovery(discFile);
|
|
211
|
+
check('flip: call transparently respawns+reattaches after daemon death',
|
|
212
|
+
recovered === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid);
|
|
213
|
+
|
|
214
|
+
await wA.stop();
|
|
215
|
+
await wB.stop();
|
|
216
|
+
await delay(700);
|
|
217
|
+
const disc = readDaemonDiscovery(discFile);
|
|
218
|
+
check('flip: daemon self-shutdown after last TUI detaches', disc === null);
|
|
219
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Last-wins ownership steal coverage with DISTINCT alive leadPids (two sleeper
|
|
223
|
+
// child processes) — the only way to observe the targeted 'superseded' frame,
|
|
224
|
+
// which is skipped when old/new client share a leadPid (same-TUI rebind).
|
|
225
|
+
async function pointerStealTest() {
|
|
226
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-steal-smoke-'));
|
|
227
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
228
|
+
const transport = createChannelDaemonTransport({
|
|
229
|
+
handleCall: async (name, args, ctx) => ({ ok: true, name, leadPid: ctx.leadPid }),
|
|
230
|
+
discoveryPath,
|
|
231
|
+
clientGraceMs: 2000,
|
|
232
|
+
sweepMs: 5000,
|
|
233
|
+
onClientsEmpty: () => {},
|
|
234
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[steal] ${m}`),
|
|
235
|
+
});
|
|
236
|
+
const { port, token } = await transport.start();
|
|
237
|
+
const discovery = { port, token, pid: process.pid };
|
|
238
|
+
const REMOTE = 'notifications/mixdog/remote';
|
|
239
|
+
|
|
240
|
+
// Two long-lived children give us two DISTINCT alive lead pids.
|
|
241
|
+
const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
242
|
+
const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
243
|
+
await delay(80);
|
|
244
|
+
|
|
245
|
+
const notA = [];
|
|
246
|
+
const notB = [];
|
|
247
|
+
const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
248
|
+
await delay(100); // A registers → A is the owner
|
|
249
|
+
const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
250
|
+
await delay(150); // B registers → steals the seat, A gets targeted superseded
|
|
251
|
+
|
|
252
|
+
check('steal: newest registrant (B) becomes the owner pointer',
|
|
253
|
+
transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
|
|
254
|
+
check('steal: displaced owner (A) got a TARGETED superseded on register',
|
|
255
|
+
notA.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
256
|
+
check('steal: new owner (B) got no superseded',
|
|
257
|
+
notB.filter((m) => m?.method === REMOTE).length === 0);
|
|
258
|
+
|
|
259
|
+
// 'acquired' now targets the owner (B) only — never broadcast to A.
|
|
260
|
+
transport.notify(REMOTE, { state: 'acquired' });
|
|
261
|
+
await delay(120);
|
|
262
|
+
check('steal: acquired targets owner (B) only, not broadcast',
|
|
263
|
+
notB.filter((m) => m?.params?.state === 'acquired').length === 1 &&
|
|
264
|
+
notA.filter((m) => m?.params?.state === 'acquired').length === 0);
|
|
265
|
+
|
|
266
|
+
// A bind-intent /call from A steals the seat back → B (displaced) superseded.
|
|
267
|
+
await clientA.call('activate_channel_bridge', {});
|
|
268
|
+
await delay(120);
|
|
269
|
+
check('steal: bind /call moves pointer back to A',
|
|
270
|
+
transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
|
|
271
|
+
check('steal: displaced owner (B) got targeted superseded on bind /call',
|
|
272
|
+
notB.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
273
|
+
|
|
274
|
+
// superseded via notify() (seat lost to another daemon) still BROADCASTS to
|
|
275
|
+
// every live client, regardless of pointer.
|
|
276
|
+
const aBefore = notA.length;
|
|
277
|
+
const bBefore = notB.length;
|
|
278
|
+
transport.notify(REMOTE, { state: 'superseded' });
|
|
279
|
+
await delay(120);
|
|
280
|
+
check('steal: superseded via notify broadcasts to ALL live clients',
|
|
281
|
+
notA.slice(aBefore).some((m) => m?.params?.state === 'superseded') &&
|
|
282
|
+
notB.slice(bBefore).some((m) => m?.params?.state === 'superseded'));
|
|
283
|
+
|
|
284
|
+
await clientA.close();
|
|
285
|
+
await clientB.close();
|
|
286
|
+
try { sleeperA.kill(); } catch {}
|
|
287
|
+
try { sleeperB.kill(); } catch {}
|
|
288
|
+
await transport.stop();
|
|
289
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Low-level helpers: register/attach WITHOUT the auto-attach client, so a
|
|
293
|
+
// client can be registered but leave its SSE stream closed (to exercise the
|
|
294
|
+
// buffered-superseded + reattach paths that attachToDaemon hides).
|
|
295
|
+
function rawPost(port, serverToken, p, body) {
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const payload = JSON.stringify(body);
|
|
298
|
+
const req = http.request({
|
|
299
|
+
hostname: '127.0.0.1', port, path: p, method: 'POST',
|
|
300
|
+
headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
301
|
+
}, (res) => { let d = ''; res.setEncoding('utf8'); res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } }); });
|
|
302
|
+
req.on('error', reject); req.write(payload); req.end();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function rawSse(port, serverToken, clientToken, onNotify) {
|
|
306
|
+
const req = http.request({
|
|
307
|
+
hostname: '127.0.0.1', port,
|
|
308
|
+
path: `/events?token=${encodeURIComponent(clientToken)}&server_token=${encodeURIComponent(serverToken)}`,
|
|
309
|
+
method: 'GET', headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
|
|
310
|
+
}, (res) => {
|
|
311
|
+
res.setEncoding('utf8'); let buf = '';
|
|
312
|
+
res.on('data', (chunk) => {
|
|
313
|
+
buf += chunk; let idx;
|
|
314
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
315
|
+
const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
|
|
316
|
+
for (const line of raw.split('\n')) {
|
|
317
|
+
if (!line.startsWith('data:')) continue;
|
|
318
|
+
const j = line.slice(5).trim(); if (!j) continue;
|
|
319
|
+
let m = null; try { m = JSON.parse(j); } catch { continue; }
|
|
320
|
+
if (m?.type === 'notify') onNotify(m);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
req.end(); return req;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Fix coverage: (1) an SSE-reconnect re-register must NOT steal ownership;
|
|
329
|
+
// (2) a targeted 'superseded' emitted while the displaced client has no live
|
|
330
|
+
// stream is buffered and flushed when its stream (re)attaches.
|
|
331
|
+
async function reattachBufferTest() {
|
|
332
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reattach-smoke-'));
|
|
333
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
334
|
+
const transport = createChannelDaemonTransport({
|
|
335
|
+
handleCall: async () => ({ ok: true }),
|
|
336
|
+
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
337
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reattach] ${m}`),
|
|
338
|
+
});
|
|
339
|
+
const { port, token } = await transport.start();
|
|
340
|
+
const REMOTE = 'notifications/mixdog/remote';
|
|
341
|
+
const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
342
|
+
const s2 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
343
|
+
await delay(80);
|
|
344
|
+
|
|
345
|
+
// (1) reconnect with the SAME leadPid: the pointer FOLLOWS to the fresh token
|
|
346
|
+
// (its old dead-stream entry is dropped), never stealing from a DIFFERENT
|
|
347
|
+
// owner. Register X (fresh) → owner; re-register reattach:true → pointer moves
|
|
348
|
+
// to the new token and the old entry is gone (no dead-stream blackhole).
|
|
349
|
+
const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
|
|
350
|
+
await delay(40);
|
|
351
|
+
check('reattach: fresh register X becomes owner',
|
|
352
|
+
transport._resolveTargetForTest()?.token === regX.token);
|
|
353
|
+
const regReconnect = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
|
|
354
|
+
await delay(40);
|
|
355
|
+
check('reattach: reconnect (same pid) pointer follows fresh token, old dropped',
|
|
356
|
+
transport._resolveTargetForTest()?.token === regReconnect.token &&
|
|
357
|
+
!transport._clientsForTest.has(regX.token));
|
|
358
|
+
|
|
359
|
+
// (2) X's current entry never opened a stream; a fresh register Y steals the
|
|
360
|
+
// seat → X's targeted 'superseded' is BUFFERED, then delivered on late attach.
|
|
361
|
+
const regY = await rawPost(port, token, '/client/register', { leadPid: s2.pid, cwd: 'Y' });
|
|
362
|
+
await delay(40);
|
|
363
|
+
check('reattach: fresh Y steals the seat from X',
|
|
364
|
+
transport._resolveTargetForTest()?.token === regY.token);
|
|
365
|
+
const notX = [];
|
|
366
|
+
const sseX = rawSse(port, token, regReconnect.token, (m) => notX.push(m));
|
|
367
|
+
await waitFor(() => notX.some((m) => m?.method === REMOTE && m?.params?.state === 'superseded'), 1000);
|
|
368
|
+
check('reattach: buffered superseded flushed to X after late SSE attach',
|
|
369
|
+
notX.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
370
|
+
|
|
371
|
+
try { sseX.destroy(); } catch {}
|
|
372
|
+
try { s1.kill(); } catch {}
|
|
373
|
+
try { s2.kill(); } catch {}
|
|
374
|
+
await transport.stop();
|
|
375
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
379
|
+
|
|
380
|
+
// Pointer-failover coverage: the pointer client dies (deregister) while a
|
|
381
|
+
// live client remains. The transport must move the pointer to the survivor
|
|
382
|
+
// (reason 'failover'), deliver the sticky 'acquired' badge to it, and
|
|
383
|
+
// re-dispatch the survivor's stored bind intent via the injected dispatchBind
|
|
384
|
+
// so the output forwarder rebinds to the survivor's transcript.
|
|
385
|
+
async function pointerFailoverTest() {
|
|
386
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-failover-smoke-'));
|
|
387
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
388
|
+
const REMOTE = 'notifications/mixdog/remote';
|
|
389
|
+
const rebinds = []; // failover re-dispatches routed through dispatchBind
|
|
390
|
+
const handleCall = async (name, args, ctx) => ({ ok: true, name, args, leadPid: ctx.leadPid });
|
|
391
|
+
const dispatchBind = (name, args, ctx) => {
|
|
392
|
+
rebinds.push({ name, args, leadPid: ctx.leadPid });
|
|
393
|
+
return handleCall(name, args, ctx);
|
|
394
|
+
};
|
|
395
|
+
const transport = createChannelDaemonTransport({
|
|
396
|
+
handleCall,
|
|
397
|
+
dispatchBind,
|
|
398
|
+
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
399
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[failover] ${m}`),
|
|
400
|
+
});
|
|
401
|
+
const { port, token } = await transport.start();
|
|
402
|
+
const discovery = { port, token, pid: process.pid };
|
|
403
|
+
|
|
404
|
+
const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
405
|
+
const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
406
|
+
await delay(80);
|
|
407
|
+
|
|
408
|
+
const notA = [];
|
|
409
|
+
const notB = [];
|
|
410
|
+
const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
411
|
+
await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' }); // A stores bind intent
|
|
412
|
+
const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
413
|
+
await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' }); // B owner + bind intent
|
|
414
|
+
await delay(120);
|
|
415
|
+
check('failover: newest binder (B) owns the pointer', transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
|
|
416
|
+
|
|
417
|
+
const aBefore = notA.length;
|
|
418
|
+
await clientB.close(); // pointer client dies → failover to survivor A
|
|
419
|
+
await waitFor(() => transport._resolveTargetForTest()?.leadPid === sleeperA.pid, 1000);
|
|
420
|
+
check('failover: pointer moves to surviving client (A)',
|
|
421
|
+
transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
|
|
422
|
+
await waitFor(() => notA.slice(aBefore).some((m) => m?.method === REMOTE && m?.params?.state === 'acquired'), 1000);
|
|
423
|
+
check('failover: survivor (A) receives the sticky acquired badge',
|
|
424
|
+
notA.slice(aBefore).filter((m) => m?.method === REMOTE && m?.params?.state === 'acquired').length === 1);
|
|
425
|
+
await waitFor(() => rebinds.some((r) => r.leadPid === sleeperA.pid), 1000);
|
|
426
|
+
check('failover: survivor bind intent re-dispatched to its transcript',
|
|
427
|
+
rebinds.some((r) => r.name === 'rebind_current_transcript'
|
|
428
|
+
&& r.args?.transcriptPath === '/tmp/a.jsonl' && r.leadPid === sleeperA.pid));
|
|
429
|
+
|
|
430
|
+
await clientA.close();
|
|
431
|
+
try { sleeperA.kill(); } catch {}
|
|
432
|
+
try { sleeperB.kill(); } catch {}
|
|
433
|
+
await transport.stop();
|
|
434
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Reattach-after-pointer-loss: the pointer client's SSE drops and it
|
|
438
|
+
// re-registers (reattach:true, SAME leadPid) with a FRESH token. The pointer
|
|
439
|
+
// must FOLLOW to the new token (old dead-stream entry dropped, no failover/
|
|
440
|
+
// self-superseded), and a notify must reach the NEW stream — not blackhole on
|
|
441
|
+
// the old entry.
|
|
442
|
+
async function pointerReconnectFollowsTest() {
|
|
443
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reconnect-smoke-'));
|
|
444
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
445
|
+
const transport = createChannelDaemonTransport({
|
|
446
|
+
handleCall: async () => ({ ok: true }),
|
|
447
|
+
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
448
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reconnect] ${m}`),
|
|
449
|
+
});
|
|
450
|
+
const { port, token } = await transport.start();
|
|
451
|
+
const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
452
|
+
await delay(80);
|
|
453
|
+
|
|
454
|
+
// Register X (fresh) → owner; open its SSE, then let the stream drop.
|
|
455
|
+
const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
|
|
456
|
+
const notOld = [];
|
|
457
|
+
const sseOld = rawSse(port, token, regX.token, (m) => notOld.push(m));
|
|
458
|
+
await delay(60);
|
|
459
|
+
try { sseOld.destroy(); } catch {}
|
|
460
|
+
await delay(40);
|
|
461
|
+
|
|
462
|
+
// Reconnect re-register (same leadPid) → fresh token; pointer must FOLLOW it.
|
|
463
|
+
const regNew = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
|
|
464
|
+
await delay(40);
|
|
465
|
+
check('reconnect: pointer follows the fresh reconnect token',
|
|
466
|
+
transport._resolveTargetForTest()?.token === regNew.token && regNew.token !== regX.token);
|
|
467
|
+
check('reconnect: dead old entry was dropped', !transport._clientsForTest.has(regX.token));
|
|
468
|
+
|
|
469
|
+
// A notify must reach the NEW stream (no blackhole on the old dead entry).
|
|
470
|
+
const notNew = [];
|
|
471
|
+
const sseNew = rawSse(port, token, regNew.token, (m) => notNew.push(m));
|
|
472
|
+
await delay(60);
|
|
473
|
+
transport.notify('notifications/claude/channel', { content: 'to-reconnect' });
|
|
474
|
+
await waitFor(() => notNew.some((m) => m?.params?.content === 'to-reconnect'), 1000);
|
|
475
|
+
check('reconnect: notify reaches the new stream, not the dead old entry',
|
|
476
|
+
notNew.some((m) => m?.params?.content === 'to-reconnect') &&
|
|
477
|
+
!notOld.some((m) => m?.params?.content === 'to-reconnect'));
|
|
478
|
+
|
|
479
|
+
try { sseNew.destroy(); } catch {}
|
|
480
|
+
try { s1.kill(); } catch {}
|
|
481
|
+
await transport.stop();
|
|
482
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
483
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Stub channels daemon for the flip smoke: same lifecycle contract as
|
|
2
|
+
// src/standalone/channel-daemon.mjs (pid-verified singleton claim, HTTP+SSE
|
|
3
|
+
// transport, discovery file, ready handshake, client-grace self-shutdown) but
|
|
4
|
+
// with a STUB runtime instead of worker-main — no Discord token needed. The
|
|
5
|
+
// real channel-worker.mjs spawn-or-attach path forks THIS via
|
|
6
|
+
// MIXDOG_CHANNEL_DAEMON_ENTRY, so the flip is exercised end to end.
|
|
7
|
+
process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
|
|
8
|
+
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
|
|
13
|
+
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
14
|
+
|
|
15
|
+
function runtimeRoot() {
|
|
16
|
+
return process.env.MIXDOG_RUNTIME_ROOT
|
|
17
|
+
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
18
|
+
: path.join(os.tmpdir(), 'mixdog');
|
|
19
|
+
}
|
|
20
|
+
const RUNTIME_ROOT = runtimeRoot();
|
|
21
|
+
const DATA_DIR = process.env.MIXDOG_DATA_DIR ? path.resolve(process.env.MIXDOG_DATA_DIR) : RUNTIME_ROOT;
|
|
22
|
+
const DISCOVERY_PATH = path.join(RUNTIME_ROOT, 'channel-daemon.json');
|
|
23
|
+
const OWNER_PATH = path.join(DATA_DIR, 'channel-daemon-owner.json');
|
|
24
|
+
|
|
25
|
+
function log(line) { if (process.env.DAEMON_SMOKE_VERBOSE) process.stderr.write(`[stub-daemon] ${line}\n`); }
|
|
26
|
+
|
|
27
|
+
let transport = null;
|
|
28
|
+
let shuttingDown = false;
|
|
29
|
+
async function shutdown(reason, code = 0) {
|
|
30
|
+
if (shuttingDown) return;
|
|
31
|
+
shuttingDown = true;
|
|
32
|
+
try { await transport?.stop?.(); } catch {}
|
|
33
|
+
try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {}
|
|
34
|
+
process.exit(code);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
try { mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
|
|
39
|
+
const claim = claimSingletonOwner(OWNER_PATH, { kind: 'channel-runtime-daemon', pid: process.pid, meta: { cwd: process.cwd() } });
|
|
40
|
+
if (!claim.owned) { process.exit(0); } // race loser → spawner attaches to winner
|
|
41
|
+
process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
|
|
42
|
+
|
|
43
|
+
// Optional env dump for the tool smoke: proves daemonEnv() spawned us with
|
|
44
|
+
// the daemon-mode flags (MIXDOG_CHANNEL_DAEMON=1, MIXDOG_CLI_OWNED=0).
|
|
45
|
+
if (process.env.SMOKE_CHANNEL_ENV_OUT) {
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
|
|
48
|
+
cliOwned: process.env.MIXDOG_CLI_OWNED,
|
|
49
|
+
daemon: process.env.MIXDOG_CHANNEL_DAEMON,
|
|
50
|
+
}));
|
|
51
|
+
} catch { /* smoke-only, best-effort */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Stub runtime: echo the call + caller identity, and (for 'fetch') emit a
|
|
55
|
+
// notify AFTER responding so the smoke can assert targeted routing.
|
|
56
|
+
const handleCall = async (name, args, ctx) => {
|
|
57
|
+
if (name === 'fetch') {
|
|
58
|
+
setTimeout(() => { try { transport.notify('notifications/claude/channel', { content: 'ping-from-stub' }); } catch {} }, 20);
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
transport = createChannelDaemonTransport({
|
|
64
|
+
handleCall,
|
|
65
|
+
discoveryPath: DISCOVERY_PATH,
|
|
66
|
+
clientGraceMs: 250,
|
|
67
|
+
sweepMs: 1000,
|
|
68
|
+
log,
|
|
69
|
+
onClientsEmpty: () => { void shutdown('no live clients'); },
|
|
70
|
+
});
|
|
71
|
+
const { port, token } = await transport.start();
|
|
72
|
+
if (process.send) { try { process.send({ type: 'ready', port, token }); } catch {} }
|
|
73
|
+
log(`ready port=${port} pid=${process.pid}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
77
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
78
|
+
process.on('message', (msg) => { if (msg && msg.type === 'shutdown') void shutdown('IPC shutdown'); });
|
|
79
|
+
|
|
80
|
+
main().catch((err) => { log(`fatal: ${err?.stack || err}`); void shutdown('fatal', 2); });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Proves the debounced skills save persists through the ASYNC config path and
|
|
3
|
+
// that the debounce-timer flush routes through async I/O (async icacls), never
|
|
4
|
+
// the sync icacls, on the timer path.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join, resolve, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const SRC = resolve(__dirname, '../src');
|
|
14
|
+
|
|
15
|
+
test('patchSkillsDisabledAsync persists agent.skills.disabled to disk (async path)', async () => {
|
|
16
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-skills-async-'));
|
|
17
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
18
|
+
// Keep the test hermetic: skip the user-data backup copy tree.
|
|
19
|
+
process.env.MIXDOG_SKIP_USER_DATA_BACKUP = '1';
|
|
20
|
+
try {
|
|
21
|
+
// Import AFTER env is set: shared/config.mjs resolves DATA_DIR at load.
|
|
22
|
+
const cfg = await import(pathToFileURL(join(SRC, 'runtime/agent/orchestrator/config.mjs')).href);
|
|
23
|
+
const out = await cfg.patchSkillsDisabledAsync(['zeta', 'alpha']);
|
|
24
|
+
assert.deepEqual(out.disabled, ['alpha', 'zeta']); // normalized + sorted
|
|
25
|
+
const onDisk = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
|
|
26
|
+
assert.deepEqual(onDisk.agent.skills.disabled, ['alpha', 'zeta']);
|
|
27
|
+
// A second async patch must last-writer-win, not append.
|
|
28
|
+
await cfg.patchSkillsDisabledAsync(['beta']);
|
|
29
|
+
const onDisk2 = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
|
|
30
|
+
assert.deepEqual(onDisk2.agent.skills.disabled, ['beta']);
|
|
31
|
+
} finally {
|
|
32
|
+
try { rmSync(dataDir, { recursive: true, force: true }); } catch {}
|
|
33
|
+
delete process.env.MIXDOG_DATA_DIR;
|
|
34
|
+
delete process.env.MIXDOG_SKIP_USER_DATA_BACKUP;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('debounce timer path routes through the async flush (async icacls), sync flush retained', () => {
|
|
39
|
+
const lifecycle = readFileSync(`${SRC}/session-runtime/config-lifecycle.mjs`, 'utf8');
|
|
40
|
+
// Skills timer schedules the ASYNC flush, and the async runner uses the async
|
|
41
|
+
// persist twin — so the timer path never hits the sync icacls RMW.
|
|
42
|
+
assert.match(lifecycle, /scheduleSkillsSave\(names\)[\s\S]*?setTimeout\(\(\) => \{ flushSkillsSaveAsync\(\); \}/);
|
|
43
|
+
assert.match(lifecycle, /runSkillsFlushAsync[\s\S]*?cfgMod\.patchSkillsDisabledAsync\(names\)/);
|
|
44
|
+
// Config/backend/outputStyle timers likewise fire the async flush.
|
|
45
|
+
assert.match(lifecycle, /setTimeout\(\(\) => \{ flushConfigSaveAsync\(\); \}/);
|
|
46
|
+
assert.match(lifecycle, /setTimeout\(\(\) => \{ flushBackendSaveAsync\(\); \}/);
|
|
47
|
+
assert.match(lifecycle, /setTimeout\(\(\) => \{ flushOutputStyleSaveAsync\(\); \}/);
|
|
48
|
+
// Sync flushes stay for reloadFullConfig/teardown durability.
|
|
49
|
+
assert.match(lifecycle, /function flushConfigSave\(\)/);
|
|
50
|
+
assert.match(lifecycle, /function flushSkillsSave\(\)/);
|
|
51
|
+
assert.match(lifecycle, /reloadFullConfig[\s\S]*?flushConfigSave\(\);/);
|
|
52
|
+
|
|
53
|
+
const atomic = readFileSync(`${SRC}/runtime/shared/atomic-file.mjs`, 'utf8');
|
|
54
|
+
// The async writer enforces the owner-only ACL via the async icacls variant.
|
|
55
|
+
assert.match(atomic, /export async function writeFileAtomicAsync[\s\S]*?await _enforceOwnerOnlyAclWin32Async\(tmp\)/);
|
|
56
|
+
assert.match(atomic, /_enforceOwnerOnlyAclWin32Async[\s\S]*?_execFileAsync\(icacls/);
|
|
57
|
+
});
|