@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.111
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 +7 -0
- package/SDK.md +102 -0
- package/package.json +44 -22
- package/src/agent-tools.ts +104 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.test.ts +1081 -65
- package/src/api.ts +1483 -90
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +95 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +191 -0
- package/src/client.test.ts +37 -0
- package/src/client.ts +844 -95
- package/src/curate.ts +111 -0
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +405 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +423 -0
- package/src/diff-watcher.test.ts +165 -0
- package/src/diff-watcher.ts +363 -0
- package/src/examples/daemon-sdk.ts +57 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +78 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +104 -0
- package/src/ghostty-thread-surface.test.ts +400 -0
- package/src/ghostty-thread-surface.ts +273 -0
- package/src/harness-delivery.test.ts +269 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +31 -0
- package/src/harness-handoff.test.ts +725 -0
- package/src/harness-handoff.ts +27 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +218 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +90 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +66 -0
- package/src/herdr-thread-surface.test.ts +465 -0
- package/src/herdr-thread-surface.ts +392 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +69 -0
- package/src/jj-working-tree.ts +140 -0
- package/src/main.ts +5 -1
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +104 -0
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +80 -0
- package/src/project-files.test.ts +134 -0
- package/src/project-files.ts +72 -0
- package/src/protocol.ts +82 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +131 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.test.ts +502 -42
- package/src/server.ts +660 -88
- package/src/share-blob.test.ts +142 -0
- package/src/share-blob.ts +57 -0
- package/src/shared-agent-protocol.ts +30 -0
- package/src/single-instance.test.ts +165 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +173 -0
- package/src/store.ts +278 -34
- package/src/testing/store-conformance.ts +192 -0
- package/src/thread-agent-validation.ts +141 -0
- package/src/thread-agent.test.ts +732 -0
- package/src/thread-agent.ts +1171 -0
- package/src/thread-review.test.ts +487 -0
- package/src/thread-review.ts +394 -0
- package/src/thread-subscription.test.ts +177 -0
- package/src/thread-subscription.ts +138 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +290 -37
- package/src/validate.ts +427 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +235 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +154 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, delimiter, isAbsolute, join } from "node:path";
|
|
4
|
+
import type { Thread, ThreadSurfaceOpenStatus } from "@cueloop/schema";
|
|
5
|
+
import type { GhosttyThreadSurfaceHandle } from "./ghostty-thread-surface-store";
|
|
6
|
+
import { loadGhosttyThreadSurface, type GhosttyThreadSurface } from "./thread-surface-config";
|
|
7
|
+
|
|
8
|
+
const APPLESCRIPT_TIMEOUT_MS = 10000;
|
|
9
|
+
const VERSION_SCRIPT = 'tell application "Ghostty" to get version';
|
|
10
|
+
export const GHOSTTY_OPEN_APPLESCRIPT = `on run argv
|
|
11
|
+
set placement to item 1 of argv
|
|
12
|
+
set threadDirectory to item 2 of argv
|
|
13
|
+
set shellCommand to item 3 of argv
|
|
14
|
+
tell application "Ghostty"
|
|
15
|
+
if placement is "tab" then
|
|
16
|
+
set targetWindow to front window
|
|
17
|
+
set beforeCount to count of tabs of targetWindow
|
|
18
|
+
set beforeIds to id of every tab of targetWindow
|
|
19
|
+
set sourceTerminal to focused terminal of selected tab of targetWindow
|
|
20
|
+
if not (perform action "new_tab" on sourceTerminal) then error "Ghostty rejected new_tab"
|
|
21
|
+
repeat 50 times
|
|
22
|
+
if (count of tabs of targetWindow) > beforeCount then exit repeat
|
|
23
|
+
delay 0.1
|
|
24
|
+
end repeat
|
|
25
|
+
if (count of tabs of targetWindow) <= beforeCount then error "Ghostty did not create a tab"
|
|
26
|
+
set newTabs to {}
|
|
27
|
+
repeat with candidateTab in tabs of targetWindow
|
|
28
|
+
if (id of candidateTab) is not in beforeIds then set end of newTabs to candidateTab
|
|
29
|
+
end repeat
|
|
30
|
+
if (count of newTabs) is not 1 then error "Ghostty tab identity is ambiguous"
|
|
31
|
+
set createdTab to item 1 of newTabs
|
|
32
|
+
set createdTerminal to focused terminal of createdTab
|
|
33
|
+
else
|
|
34
|
+
set surfaceConfiguration to new surface configuration
|
|
35
|
+
set initial working directory of surfaceConfiguration to threadDirectory
|
|
36
|
+
if placement is "window" then
|
|
37
|
+
set createdWindow to new window with configuration surfaceConfiguration
|
|
38
|
+
set createdTerminal to focused terminal of selected tab of createdWindow
|
|
39
|
+
else
|
|
40
|
+
set targetWindow to front window
|
|
41
|
+
set sourceTerminal to focused terminal of selected tab of targetWindow
|
|
42
|
+
set createdTerminal to split sourceTerminal direction right with configuration surfaceConfiguration
|
|
43
|
+
end if
|
|
44
|
+
end if
|
|
45
|
+
try
|
|
46
|
+
repeat 50 times
|
|
47
|
+
if (name of createdTerminal) is not "👻" and (name of createdTerminal) is not "" then exit repeat
|
|
48
|
+
delay 0.1
|
|
49
|
+
end repeat
|
|
50
|
+
if (name of createdTerminal) is "👻" or (name of createdTerminal) is "" then error "Ghostty surface has no shell"
|
|
51
|
+
input text shellCommand to createdTerminal
|
|
52
|
+
send key "enter" to createdTerminal
|
|
53
|
+
focus createdTerminal
|
|
54
|
+
return id of createdTerminal
|
|
55
|
+
on error
|
|
56
|
+
if placement is "tab" then
|
|
57
|
+
close tab createdTab
|
|
58
|
+
else if placement is "window" then
|
|
59
|
+
close window createdWindow
|
|
60
|
+
else
|
|
61
|
+
close createdTerminal
|
|
62
|
+
end if
|
|
63
|
+
error "Ghostty surface did not launch the Thread"
|
|
64
|
+
end try
|
|
65
|
+
end tell
|
|
66
|
+
end run`;
|
|
67
|
+
const FOCUS_SCRIPT = `on run argv
|
|
68
|
+
set targetId to item 1 of argv
|
|
69
|
+
tell application "Ghostty"
|
|
70
|
+
repeat with candidate in terminals
|
|
71
|
+
if (id of candidate) is targetId then
|
|
72
|
+
focus candidate
|
|
73
|
+
return "focused"
|
|
74
|
+
end if
|
|
75
|
+
end repeat
|
|
76
|
+
end tell
|
|
77
|
+
return "closed"
|
|
78
|
+
end run`;
|
|
79
|
+
const CLOSE_SCRIPT = `on run argv
|
|
80
|
+
set targetId to item 1 of argv
|
|
81
|
+
tell application "Ghostty"
|
|
82
|
+
repeat with candidate in terminals
|
|
83
|
+
if (id of candidate) is targetId then
|
|
84
|
+
close candidate
|
|
85
|
+
return "closed"
|
|
86
|
+
end if
|
|
87
|
+
end repeat
|
|
88
|
+
end tell
|
|
89
|
+
return "absent"
|
|
90
|
+
end run`;
|
|
91
|
+
|
|
92
|
+
export interface GhosttyThreadSurfacePersistence {
|
|
93
|
+
ghosttyClaimThreadSurface(threadId: string): Promise<boolean>;
|
|
94
|
+
ghosttyReleaseThreadSurface(threadId: string): Promise<void>;
|
|
95
|
+
ghosttyGetThreadSurface(threadId: string): Promise<GhosttyThreadSurfaceHandle | null>;
|
|
96
|
+
ghosttySetThreadSurface(threadId: string, handle: GhosttyThreadSurfaceHandle): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface GhosttyEnv {
|
|
100
|
+
[name: string]: string | undefined;
|
|
101
|
+
TERM_PROGRAM?: string;
|
|
102
|
+
GHOSTTY_RESOURCES_DIR?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface GhosttyLaunchResult {
|
|
106
|
+
status: ThreadSurfaceOpenStatus;
|
|
107
|
+
retainClaim: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function runAppleScript(binPath: string, script: string, args: string[] = []): string | null {
|
|
111
|
+
try {
|
|
112
|
+
const result = spawnSync(binPath, ["-e", script, ...args], {
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
115
|
+
timeout: APPLESCRIPT_TIMEOUT_MS,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveCueloopLaunchCommand(
|
|
125
|
+
executable = process.execPath,
|
|
126
|
+
entry = process.argv[1],
|
|
127
|
+
path = process.env.PATH,
|
|
128
|
+
): string | null {
|
|
129
|
+
if (basename(executable) === "cueloop") return shellQuote(executable);
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
entry &&
|
|
133
|
+
isAbsolute(entry) &&
|
|
134
|
+
entry.endsWith(join("packages", "cli", "src", "main.ts")) &&
|
|
135
|
+
existsSync(entry)
|
|
136
|
+
) {
|
|
137
|
+
return `${shellQuote(executable)} run ${shellQuote(entry)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const directory of path?.split(delimiter) ?? []) {
|
|
141
|
+
if (!isAbsolute(directory)) continue;
|
|
142
|
+
|
|
143
|
+
const candidate = join(directory, "cueloop");
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
accessSync(candidate, constants.X_OK);
|
|
147
|
+
|
|
148
|
+
if (statSync(candidate).isFile()) return shellQuote(candidate);
|
|
149
|
+
} catch {
|
|
150
|
+
// A PATH entry without an executable cannot launch the Thread.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function shellQuote(value: string): string {
|
|
158
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Ghostty 1.3.0 introduced the native AppleScript commands used here. */
|
|
162
|
+
export function supportsGhosttyAppleScript(version: string | null): boolean {
|
|
163
|
+
const parsed = version?.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
164
|
+
|
|
165
|
+
if (!parsed) return false;
|
|
166
|
+
|
|
167
|
+
const major = Number(parsed[1]);
|
|
168
|
+
const minor = Number(parsed[2]);
|
|
169
|
+
|
|
170
|
+
return major > 1 || (major === 1 && minor >= 3);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Herdr selection happens before this adapter, even when it runs inside Ghostty. */
|
|
174
|
+
export function insideGhostty(env: GhosttyEnv = process.env): boolean {
|
|
175
|
+
return env.TERM_PROGRAM?.toLowerCase() === "ghostty" || Boolean(env.GHOSTTY_RESOURCES_DIR);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Reuse a live Ghostty terminal, reopen a closed one, or leave the Thread pending. */
|
|
179
|
+
export async function openGhosttyThreadSurface(
|
|
180
|
+
thread: Thread,
|
|
181
|
+
persistence: GhosttyThreadSurfacePersistence,
|
|
182
|
+
env: GhosttyEnv = process.env,
|
|
183
|
+
mode: GhosttyThreadSurface = loadGhosttyThreadSurface(),
|
|
184
|
+
binPath = "osascript",
|
|
185
|
+
platform: NodeJS.Platform = process.platform,
|
|
186
|
+
cueloopBinPath?: string | null,
|
|
187
|
+
): Promise<ThreadSurfaceOpenStatus> {
|
|
188
|
+
if (platform !== "darwin" || !insideGhostty(env)) return "unavailable";
|
|
189
|
+
|
|
190
|
+
if (mode === "none") return "disabled";
|
|
191
|
+
|
|
192
|
+
if (!supportsGhosttyAppleScript(runAppleScript(binPath, VERSION_SCRIPT))) return "failed";
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
if (!(await persistence.ghosttyClaimThreadSurface(thread.id))) return "failed";
|
|
196
|
+
} catch {
|
|
197
|
+
return "failed";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let result: GhosttyLaunchResult;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
result = await openClaimedGhosttyThreadSurface(
|
|
204
|
+
thread,
|
|
205
|
+
persistence,
|
|
206
|
+
mode,
|
|
207
|
+
binPath,
|
|
208
|
+
cueloopBinPath,
|
|
209
|
+
);
|
|
210
|
+
} catch {
|
|
211
|
+
result = { status: "failed", retainClaim: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (result.retainClaim) return "failed";
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await persistence.ghosttyReleaseThreadSurface(thread.id);
|
|
218
|
+
} catch {
|
|
219
|
+
return "failed";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return result.status;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function openClaimedGhosttyThreadSurface(
|
|
226
|
+
thread: Thread,
|
|
227
|
+
persistence: GhosttyThreadSurfacePersistence,
|
|
228
|
+
mode: Exclude<GhosttyThreadSurface, "none">,
|
|
229
|
+
binPath: string,
|
|
230
|
+
cueloopBinPath?: string | null,
|
|
231
|
+
): Promise<GhosttyLaunchResult> {
|
|
232
|
+
let recorded: GhosttyThreadSurfaceHandle | null;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
recorded = await persistence.ghosttyGetThreadSurface(thread.id);
|
|
236
|
+
} catch {
|
|
237
|
+
return { status: "failed", retainClaim: false };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (recorded) {
|
|
241
|
+
const result = runAppleScript(binPath, FOCUS_SCRIPT, [recorded.terminalId]);
|
|
242
|
+
|
|
243
|
+
if (result === "focused") return { status: "focused", retainClaim: false };
|
|
244
|
+
|
|
245
|
+
if (result !== "closed") return { status: "failed", retainClaim: false };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const launchCommand =
|
|
249
|
+
cueloopBinPath === undefined
|
|
250
|
+
? resolveCueloopLaunchCommand()
|
|
251
|
+
: cueloopBinPath && shellQuote(cueloopBinPath);
|
|
252
|
+
|
|
253
|
+
if (!launchCommand) return { status: "failed", retainClaim: false };
|
|
254
|
+
|
|
255
|
+
const cwd = thread.artifact.meta.cwd ?? thread.workspace.repoRoot;
|
|
256
|
+
const terminalId = runAppleScript(binPath, GHOSTTY_OPEN_APPLESCRIPT, [
|
|
257
|
+
mode,
|
|
258
|
+
cwd,
|
|
259
|
+
`cd -- ${shellQuote(cwd)} && ${launchCommand} ${shellQuote(thread.id)}`,
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
if (!terminalId) return { status: "failed", retainClaim: false };
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await persistence.ghosttySetThreadSurface(thread.id, { terminalId });
|
|
266
|
+
} catch {
|
|
267
|
+
const closed = runAppleScript(binPath, CLOSE_SCRIPT, [terminalId]);
|
|
268
|
+
|
|
269
|
+
return { status: "failed", retainClaim: closed !== "closed" && closed !== "absent" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { status: "opened", retainClaim: false };
|
|
273
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonCore } from "./api";
|
|
6
|
+
import { harnessStatePath } from "./paths";
|
|
7
|
+
|
|
8
|
+
let home: string;
|
|
9
|
+
let cores: DaemonCore[];
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-harness-delivery-"));
|
|
13
|
+
cores = [];
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const core of cores) core.dispose();
|
|
18
|
+
rmSync(home, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function core(): DaemonCore {
|
|
22
|
+
const instance = new DaemonCore(home);
|
|
23
|
+
|
|
24
|
+
cores.push(instance);
|
|
25
|
+
|
|
26
|
+
return instance;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createPlan(instance: DaemonCore) {
|
|
30
|
+
return instance.sessionCreate({
|
|
31
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
32
|
+
artifact: { type: "plan", content: "# Plan", meta: {} },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("durable harness delivery", () => {
|
|
37
|
+
test("delivers Comment without resolving the Thread", () => {
|
|
38
|
+
const instance = core();
|
|
39
|
+
const thread = createPlan(instance);
|
|
40
|
+
const binding = instance.harnessBind({
|
|
41
|
+
threadId: thread.id,
|
|
42
|
+
harness: "fake",
|
|
43
|
+
harnessSessionId: "fake_1",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const sent = instance.sessionSendMessage(thread.id, "comment", "Interim note.");
|
|
47
|
+
const pending = instance.deliveryPending(binding.id);
|
|
48
|
+
|
|
49
|
+
expect(sent.status).toBe("pending");
|
|
50
|
+
expect(pending).toHaveLength(1);
|
|
51
|
+
expect(pending[0]!.message.outcome).toBe("comment");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("reload finds only bindings for the active harness conversation", () => {
|
|
55
|
+
const first = core();
|
|
56
|
+
const thread = createPlan(first);
|
|
57
|
+
const current = first.harnessBind({
|
|
58
|
+
threadId: thread.id,
|
|
59
|
+
harness: "pi",
|
|
60
|
+
harnessSessionId: "pi-current",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
first.harnessBind({ threadId: thread.id, harness: "pi", harnessSessionId: "pi-other" });
|
|
64
|
+
first.harnessBind({ threadId: thread.id, harness: "codex", harnessSessionId: "pi-current" });
|
|
65
|
+
|
|
66
|
+
expect(core().harnessBindingsForSession("pi", "pi-current")).toEqual([current]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("Thread deletion removes pi, Codex, and Claude harness state", () => {
|
|
70
|
+
const instance = core();
|
|
71
|
+
const thread = createPlan(instance);
|
|
72
|
+
const bindings = ["pi", "codex", "claude-code"].map((harness) =>
|
|
73
|
+
instance.harnessBind({
|
|
74
|
+
threadId: thread.id,
|
|
75
|
+
harness,
|
|
76
|
+
harnessSessionId: `${harness}-session`,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
81
|
+
const deliveryId = instance.deliveryPending(bindings[0]!.id)[0]!.delivery.id;
|
|
82
|
+
|
|
83
|
+
instance.sessionDelete(thread.id);
|
|
84
|
+
|
|
85
|
+
for (const [index, harness] of ["pi", "codex", "claude-code"].entries()) {
|
|
86
|
+
expect(instance.harnessBindingsForSession(harness, `${harness}-session`)).toEqual([]);
|
|
87
|
+
expect(() => instance.harnessGetBinding(bindings[index]!.id)).toThrow("no harness binding");
|
|
88
|
+
}
|
|
89
|
+
expect(() => instance.deliveryAcknowledge(deliveryId)).toThrow("no delivery");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("startup removes harness state orphaned by an older deletion", () => {
|
|
93
|
+
const first = core();
|
|
94
|
+
const thread = createPlan(first);
|
|
95
|
+
const binding = first.harnessBind({
|
|
96
|
+
threadId: thread.id,
|
|
97
|
+
harness: "codex",
|
|
98
|
+
harnessSessionId: "codex-orphan",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
first.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
102
|
+
const deliveryId = first.deliveryPending(binding.id)[0]!.delivery.id;
|
|
103
|
+
|
|
104
|
+
first.store.delete(thread.id);
|
|
105
|
+
const restarted = core();
|
|
106
|
+
|
|
107
|
+
expect(restarted.harnessBindingsForSession("codex", "codex-orphan")).toEqual([]);
|
|
108
|
+
expect(() => restarted.harnessGetBinding(binding.id)).toThrow("no harness binding");
|
|
109
|
+
expect(() => restarted.deliveryAcknowledge(deliveryId)).toThrow("no delivery");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("redelivers after reload until the harness acknowledges the Message", () => {
|
|
113
|
+
const first = core();
|
|
114
|
+
const thread = createPlan(first);
|
|
115
|
+
const binding = first.harnessBind({
|
|
116
|
+
threadId: thread.id,
|
|
117
|
+
harness: "fake",
|
|
118
|
+
harnessSessionId: "fake_1",
|
|
119
|
+
});
|
|
120
|
+
const sent = first.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
|
|
121
|
+
const initial = first.deliveryPending(binding.id);
|
|
122
|
+
|
|
123
|
+
expect(initial).toHaveLength(1);
|
|
124
|
+
expect(initial[0]!.message).toEqual(sent.message!);
|
|
125
|
+
|
|
126
|
+
const reloaded = core();
|
|
127
|
+
const redelivered = reloaded.deliveryPending(binding.id);
|
|
128
|
+
|
|
129
|
+
expect(redelivered).toEqual(initial);
|
|
130
|
+
reloaded.deliveryAcknowledge(redelivered[0]!.delivery.id);
|
|
131
|
+
|
|
132
|
+
expect(core().deliveryPending(binding.id)).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("retains an expired Thread until its pending Message is acknowledged", () => {
|
|
136
|
+
const first = core();
|
|
137
|
+
const thread = createPlan(first);
|
|
138
|
+
const binding = first.harnessBind({
|
|
139
|
+
threadId: thread.id,
|
|
140
|
+
harness: "fake",
|
|
141
|
+
harnessSessionId: "fake_1",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
first.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
145
|
+
const oldThread = first.sessionGet(thread.id);
|
|
146
|
+
|
|
147
|
+
first.store.upsert({ ...oldThread, createdAt: "2020-01-01T00:00:00.000Z" });
|
|
148
|
+
|
|
149
|
+
const reloaded = core();
|
|
150
|
+
const delivery = reloaded.deliveryPending(binding.id)[0];
|
|
151
|
+
|
|
152
|
+
expect(delivery?.message.body).toContain("Ready.");
|
|
153
|
+
reloaded.deliveryAcknowledge(delivery!.delivery.id);
|
|
154
|
+
|
|
155
|
+
expect(core().store.get(thread.id)).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("rebuilds a delivery if the Thread saved its Message before enqueue", () => {
|
|
159
|
+
const first = core();
|
|
160
|
+
const thread = createPlan(first);
|
|
161
|
+
const binding = first.harnessBind({
|
|
162
|
+
threadId: thread.id,
|
|
163
|
+
harness: "fake",
|
|
164
|
+
harnessSessionId: "fake_1",
|
|
165
|
+
});
|
|
166
|
+
const sent = first.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
167
|
+
|
|
168
|
+
first.store.upsert({
|
|
169
|
+
...first.sessionGet(thread.id),
|
|
170
|
+
createdAt: "2020-01-01T00:00:00.000Z",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
writeFileSync(harnessStatePath(home), JSON.stringify({ bindings: [binding], deliveries: [] }));
|
|
174
|
+
|
|
175
|
+
const recovered = core().deliveryPending(binding.id);
|
|
176
|
+
|
|
177
|
+
expect(recovered).toHaveLength(1);
|
|
178
|
+
expect(recovered[0]!.message).toEqual(sent.message!);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("routes an approved retry as a new Message", () => {
|
|
182
|
+
const instance = core();
|
|
183
|
+
const thread = createPlan(instance);
|
|
184
|
+
const binding = instance.harnessBind({
|
|
185
|
+
threadId: thread.id,
|
|
186
|
+
harness: "fake",
|
|
187
|
+
harnessSessionId: "fake_1",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
instance.sessionSendMessage(thread.id, "changes_requested", "Try once more.");
|
|
191
|
+
const first = instance.deliveryPending(binding.id)[0]!;
|
|
192
|
+
|
|
193
|
+
instance.deliveryAcknowledge(first.delivery.id);
|
|
194
|
+
instance.sessionSubmitRevision(thread.id, "# Plan");
|
|
195
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
196
|
+
const retry = instance.deliveryPending(binding.id);
|
|
197
|
+
|
|
198
|
+
expect(retry).toHaveLength(1);
|
|
199
|
+
expect(retry[0]!.message.outcome).toBe("approved");
|
|
200
|
+
expect(retry[0]!.message.id).not.toBe(first.message.id);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("keeps an older pending Message available after a Thread revision", () => {
|
|
204
|
+
const instance = core();
|
|
205
|
+
const thread = createPlan(instance);
|
|
206
|
+
const binding = instance.harnessBind({
|
|
207
|
+
threadId: thread.id,
|
|
208
|
+
harness: "fake",
|
|
209
|
+
harnessSessionId: "fake_1",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
instance.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
|
|
213
|
+
const first = instance.deliveryPending(binding.id)[0]!;
|
|
214
|
+
|
|
215
|
+
instance.sessionSubmitRevision(thread.id, "# Revised plan");
|
|
216
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
217
|
+
|
|
218
|
+
const pending = core().deliveryPending(binding.id);
|
|
219
|
+
|
|
220
|
+
expect(pending.map((item) => item.message.id)).toEqual([first.message.id, expect.any(String)]);
|
|
221
|
+
expect(pending[0]!.message.body).toBe(first.message.body);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("defaults delivery to the submitting binding only", () => {
|
|
225
|
+
const instance = core();
|
|
226
|
+
const thread = createPlan(instance);
|
|
227
|
+
const submitter = instance.harnessBind({
|
|
228
|
+
threadId: thread.id,
|
|
229
|
+
harness: "fake",
|
|
230
|
+
harnessSessionId: "fake_1",
|
|
231
|
+
});
|
|
232
|
+
const other = instance.harnessBind({
|
|
233
|
+
threadId: thread.id,
|
|
234
|
+
harness: "other",
|
|
235
|
+
harnessSessionId: "other_1",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
239
|
+
|
|
240
|
+
expect(instance.deliveryPending(submitter.id)).toHaveLength(1);
|
|
241
|
+
expect(instance.deliveryPending(other.id)).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("consumes the unchanged approved retry once across daemon reload", () => {
|
|
245
|
+
const instance = core();
|
|
246
|
+
const thread = createPlan(instance);
|
|
247
|
+
const binding = instance.harnessBind({
|
|
248
|
+
threadId: thread.id,
|
|
249
|
+
harness: "fake",
|
|
250
|
+
harnessSessionId: "fake_1",
|
|
251
|
+
});
|
|
252
|
+
const sent = instance.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
253
|
+
const messageId = sent.message!.id;
|
|
254
|
+
|
|
255
|
+
expect(() => instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toThrow(
|
|
256
|
+
"no unchanged approved plan",
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const delivery = instance.deliveryPending(binding.id)[0]!.delivery;
|
|
260
|
+
|
|
261
|
+
instance.deliveryAcknowledge(delivery.id);
|
|
262
|
+
|
|
263
|
+
expect(instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(true);
|
|
264
|
+
expect(core().harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(false);
|
|
265
|
+
expect(() => core().harnessConsumeApprovedRetry(binding.id, messageId, "# Changed")).toThrow(
|
|
266
|
+
"no unchanged approved plan",
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { writeHarnessDiagnostic } from "./harness-diagnostics";
|
|
6
|
+
|
|
7
|
+
test("diagnostic logs are private and bounded across repeated notices", () => {
|
|
8
|
+
const directory = mkdtempSync(join(tmpdir(), "cueloop-diagnostics-"));
|
|
9
|
+
const path = join(directory, "thread.diagnostics.ndjson");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
for (let i = 0; i < 100; i++)
|
|
13
|
+
writeHarnessDiagnostic(directory, "thread", {
|
|
14
|
+
kind: "diagnostic",
|
|
15
|
+
severity: "error",
|
|
16
|
+
source: "protocol",
|
|
17
|
+
title: "Advisory",
|
|
18
|
+
text: "x".repeat(32768),
|
|
19
|
+
});
|
|
20
|
+
expect(statSync(path).size).toBeLessThanOrEqual(256 * 1024);
|
|
21
|
+
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
22
|
+
expect(JSON.parse(readFileSync(path, "utf8").trim().split("\n")[0]!).text).toHaveLength(16384);
|
|
23
|
+
} finally {
|
|
24
|
+
rmSync(directory, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AgentHarnessDiagnostic } from "@cueloop/schema";
|
|
4
|
+
|
|
5
|
+
/** Store harness diagnostics in a private, bounded log, outside Thread history and model context. */
|
|
6
|
+
export function writeHarnessDiagnostic(
|
|
7
|
+
directory: string,
|
|
8
|
+
threadId: string,
|
|
9
|
+
diagnostic: AgentHarnessDiagnostic,
|
|
10
|
+
): void {
|
|
11
|
+
const path = join(directory, `${encodeURIComponent(threadId)}.diagnostics.ndjson`);
|
|
12
|
+
const record =
|
|
13
|
+
JSON.stringify({
|
|
14
|
+
at: new Date().toISOString(),
|
|
15
|
+
...diagnostic,
|
|
16
|
+
severity: diagnostic.severity.slice(0, 128),
|
|
17
|
+
title: diagnostic.title.slice(0, 512),
|
|
18
|
+
text: diagnostic.text.slice(0, 16384),
|
|
19
|
+
}) + "\n";
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
23
|
+
|
|
24
|
+
if (existsSync(path) && statSync(path).size + Buffer.byteLength(record) > 256 * 1024)
|
|
25
|
+
writeFileSync(path, record, { mode: 0o600 });
|
|
26
|
+
else appendFileSync(path, record, { mode: 0o600 });
|
|
27
|
+
} catch {
|
|
28
|
+
// A diagnostic log failure must not change the agent turn outcome.
|
|
29
|
+
console.error("Thread agent diagnostic log could not be written");
|
|
30
|
+
}
|
|
31
|
+
}
|