chapterhouse 0.3.0 → 0.3.1
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 +23 -0
- package/dist/copilot/orchestrator.js +13 -2
- package/dist/copilot/orchestrator.test.js +1 -1
- package/dist/daemon-install.js +62 -3
- package/dist/daemon-install.test.js +35 -0
- package/dist/daemon.js +19 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,6 +307,29 @@ chapterhouse daemon uninstall # stop, disable, and remove the unit file
|
|
|
307
307
|
| macOS | `~/Library/Logs/chapterhouse.log` |
|
|
308
308
|
| Linux | `journalctl --user -u chapterhouse` (no extra config needed) |
|
|
309
309
|
|
|
310
|
+
#### Timing contract
|
|
311
|
+
|
|
312
|
+
Chapterhouse enforces a **3-layer timing contract** so in-flight LLM streams can finish cleanly before the process is killed:
|
|
313
|
+
|
|
314
|
+
| Layer | What it controls | Config | Default |
|
|
315
|
+
|-------|-----------------|--------|---------|
|
|
316
|
+
| 1 — Orchestrator turn | How long the orchestrator waits per LLM turn | `CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS` | `1800000` (30 min) |
|
|
317
|
+
| 2 — Daemon shutdown grace | How long the daemon waits for in-flight work before force-exiting | `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS` | `60000` (60 s) |
|
|
318
|
+
| 3 — systemd kill window | How long systemd waits after SIGTERM before sending SIGKILL | `TimeoutStopSec` in generated unit | `90 s` (fixed) |
|
|
319
|
+
|
|
320
|
+
**Rule:** each layer must exceed the one above it. Do not tighten `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS` below `CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS`, and do not reduce `TimeoutStopSec` below `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS`.
|
|
321
|
+
|
|
322
|
+
#### Daemon PATH
|
|
323
|
+
|
|
324
|
+
The generated systemd unit and launchd plist compose a rich `PATH` that includes:
|
|
325
|
+
- The installing shell's `$PATH` (captured at install time)
|
|
326
|
+
- The binary's own directory
|
|
327
|
+
- Linuxbrew (`/home/linuxbrew/.linuxbrew/bin`), Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)
|
|
328
|
+
- `~/.cargo/bin`, `~/.bun/bin`, `~/.volta/bin`, `~/.local/bin`
|
|
329
|
+
- Standard system paths
|
|
330
|
+
|
|
331
|
+
This ensures sub-agents and spawned tools can find CLI dependencies even in a headless service context.
|
|
332
|
+
|
|
310
333
|
## Web UI
|
|
311
334
|
|
|
312
335
|
The browser app at `http://localhost:7788` is split into a few views:
|
|
@@ -315,8 +315,19 @@ export async function initOrchestrator(client) {
|
|
|
315
315
|
log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
|
-
/** How long to wait for the orchestrator to finish a turn (
|
|
319
|
-
|
|
318
|
+
/** How long to wait for the orchestrator to finish a turn (30 min default).
|
|
319
|
+
* Override with CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS env var (parsed as integer ms).
|
|
320
|
+
* Part of the 3-layer timing contract — see systemd unit TimeoutStopSec comment. */
|
|
321
|
+
const DEFAULT_ORCHESTRATOR_TIMEOUT_MS = 1_800_000;
|
|
322
|
+
export const ORCHESTRATOR_TIMEOUT_MS = (() => {
|
|
323
|
+
const env = process.env.CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS;
|
|
324
|
+
if (env) {
|
|
325
|
+
const parsed = parseInt(env, 10);
|
|
326
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
327
|
+
return parsed;
|
|
328
|
+
}
|
|
329
|
+
return DEFAULT_ORCHESTRATOR_TIMEOUT_MS;
|
|
330
|
+
})();
|
|
320
331
|
/** Send a prompt on a session identified by sessionKey, return the response. */
|
|
321
332
|
async function executeOnSession(sessionKey, prompt, callback, attachments, onActivity) {
|
|
322
333
|
const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
|
|
@@ -619,7 +619,7 @@ test("S5-01: subagent.failed event updates agent_tasks status to error", async (
|
|
|
619
619
|
toolCallId: "subagent-call-003",
|
|
620
620
|
agentName: "Zoe",
|
|
621
621
|
agentDisplayName: "Zoe — QA",
|
|
622
|
-
error: "Timeout after
|
|
622
|
+
error: "Timeout after 1800s",
|
|
623
623
|
});
|
|
624
624
|
const errorWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("error"));
|
|
625
625
|
assert.ok(errorWrite, "subagent.failed must UPDATE agent_tasks to error status");
|
package/dist/daemon-install.js
CHANGED
|
@@ -53,9 +53,35 @@ export function resolveChapterhouseBin() {
|
|
|
53
53
|
return argv1;
|
|
54
54
|
return "chapterhouse";
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Compose a rich PATH for launchd-spawned children (macOS).
|
|
58
|
+
* Prepends the installing shell's $PATH so any tool already on the user's PATH
|
|
59
|
+
* is available to sub-agents, then adds Homebrew and language-toolchain dirs.
|
|
60
|
+
*/
|
|
61
|
+
function composeMacOSPath(binDir, shellPath) {
|
|
62
|
+
const home = homedir();
|
|
63
|
+
return [
|
|
64
|
+
shellPath,
|
|
65
|
+
binDir,
|
|
66
|
+
"/opt/homebrew/bin",
|
|
67
|
+
"/usr/local/bin",
|
|
68
|
+
`${home}/.cargo/bin`,
|
|
69
|
+
`${home}/.bun/bin`,
|
|
70
|
+
`${home}/.volta/bin`,
|
|
71
|
+
`${home}/.local/bin`,
|
|
72
|
+
"/usr/bin",
|
|
73
|
+
"/bin",
|
|
74
|
+
"/usr/sbin",
|
|
75
|
+
"/sbin",
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(":");
|
|
79
|
+
}
|
|
56
80
|
/** Generate the launchd plist XML string. */
|
|
57
81
|
export function generatePlist(options) {
|
|
58
82
|
const label = options.label ?? DAEMON_LABEL;
|
|
83
|
+
const shellPath = options.shellPath ?? process.env.PATH ?? "";
|
|
84
|
+
const richPath = composeMacOSPath(dirname(options.binPath), shellPath);
|
|
59
85
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
60
86
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
61
87
|
<plist version="1.0">
|
|
@@ -78,16 +104,39 @@ export function generatePlist(options) {
|
|
|
78
104
|
<key>EnvironmentVariables</key>
|
|
79
105
|
<dict>
|
|
80
106
|
<key>PATH</key>
|
|
81
|
-
<string>${
|
|
107
|
+
<string>${richPath}</string>
|
|
82
108
|
</dict>
|
|
83
109
|
</dict>
|
|
84
110
|
</plist>
|
|
85
111
|
`;
|
|
86
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Compose a rich PATH for systemd-spawned children (Linux).
|
|
115
|
+
* Uses systemd's %h specifier for the home directory so the unit stays portable
|
|
116
|
+
* across uid changes. Prepends the installing shell's $PATH for robustness.
|
|
117
|
+
*/
|
|
118
|
+
function composeSystemdPath(binDir, shellPath) {
|
|
119
|
+
return [
|
|
120
|
+
shellPath,
|
|
121
|
+
binDir,
|
|
122
|
+
"/home/linuxbrew/.linuxbrew/bin",
|
|
123
|
+
"%h/.cargo/bin",
|
|
124
|
+
"%h/.bun/bin",
|
|
125
|
+
"%h/.volta/bin",
|
|
126
|
+
"%h/.local/bin",
|
|
127
|
+
"/opt/homebrew/bin",
|
|
128
|
+
"/usr/local/bin",
|
|
129
|
+
"/usr/bin",
|
|
130
|
+
"/bin",
|
|
131
|
+
]
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join(":");
|
|
134
|
+
}
|
|
87
135
|
/** Generate the systemd user unit file string. */
|
|
88
136
|
export function generateSystemdUnit(options) {
|
|
89
137
|
const description = options.description ?? "Chapterhouse AI assistant daemon";
|
|
90
|
-
const
|
|
138
|
+
const shellPath = options.shellPath ?? process.env.PATH ?? "";
|
|
139
|
+
const richPath = composeSystemdPath(dirname(options.binPath), shellPath);
|
|
91
140
|
return `[Unit]
|
|
92
141
|
Description=${description}
|
|
93
142
|
After=network.target
|
|
@@ -97,7 +146,17 @@ Type=simple
|
|
|
97
146
|
ExecStart=${options.binPath} start
|
|
98
147
|
Restart=on-failure
|
|
99
148
|
RestartSec=5s
|
|
100
|
-
|
|
149
|
+
# Rich PATH ensures spawned sub-agents and tools can find CLI dependencies
|
|
150
|
+
# (Linuxbrew, Homebrew, Cargo, Bun, Volta). %h is expanded to $HOME by systemd.
|
|
151
|
+
Environment="PATH=${richPath}"
|
|
152
|
+
# ── Timing contract (three layers) ─────────────────────────────────────────────
|
|
153
|
+
# Layer 1 │ Orchestrator turn timeout │ CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS (default 1800 s)
|
|
154
|
+
# Layer 2 │ Daemon graceful shutdown │ CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS (default 60 s)
|
|
155
|
+
# Layer 3 │ systemd force-kill window │ TimeoutStopSec = 90 s (must exceed layer 2)
|
|
156
|
+
# Each layer must exceed the one above it. Do NOT tighten TimeoutStopSec without
|
|
157
|
+
# first reducing CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS, or in-flight LLM streams will
|
|
158
|
+
# be killed mid-response.
|
|
159
|
+
TimeoutStopSec=90
|
|
101
160
|
|
|
102
161
|
[Install]
|
|
103
162
|
WantedBy=default.target
|
|
@@ -41,6 +41,23 @@ test("generatePlist includes bin directory in PATH environment", () => {
|
|
|
41
41
|
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
|
|
42
42
|
assert.ok(plist.includes(dirname(MOCK_BIN_DARWIN)), "plist PATH should include bin directory");
|
|
43
43
|
});
|
|
44
|
+
test("generatePlist PATH includes Homebrew and toolchain directories", () => {
|
|
45
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, shellPath: "" });
|
|
46
|
+
const home = homedir();
|
|
47
|
+
assert.ok(plist.includes("/opt/homebrew/bin"), "plist PATH should include /opt/homebrew/bin");
|
|
48
|
+
assert.ok(plist.includes(`${home}/.cargo/bin`), "plist PATH should include ~/.cargo/bin");
|
|
49
|
+
assert.ok(plist.includes(`${home}/.bun/bin`), "plist PATH should include ~/.bun/bin");
|
|
50
|
+
assert.ok(plist.includes(`${home}/.volta/bin`), "plist PATH should include ~/.volta/bin");
|
|
51
|
+
assert.ok(plist.includes(`${home}/.local/bin`), "plist PATH should include ~/.local/bin");
|
|
52
|
+
});
|
|
53
|
+
test("generatePlist PATH prepends installing shell PATH", () => {
|
|
54
|
+
const shellPath = "/custom/shell/bin:/another/dir";
|
|
55
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, shellPath });
|
|
56
|
+
assert.ok(plist.includes(shellPath), "plist PATH should include the installing shell PATH");
|
|
57
|
+
const pathStart = plist.indexOf(shellPath);
|
|
58
|
+
const binDirStart = plist.indexOf(dirname(MOCK_BIN_DARWIN), plist.indexOf("<key>PATH</key>"));
|
|
59
|
+
assert.ok(pathStart < binDirStart, "shell PATH should appear before bin dir in composed PATH");
|
|
60
|
+
});
|
|
44
61
|
test("generatePlist respects a custom label override", () => {
|
|
45
62
|
const customLabel = "com.example.test";
|
|
46
63
|
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, label: customLabel });
|
|
@@ -78,6 +95,24 @@ test("generateSystemdUnit includes bin directory in PATH", () => {
|
|
|
78
95
|
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
79
96
|
assert.ok(unit.includes(dirname(MOCK_BIN_LINUX)), "unit PATH should include bin directory");
|
|
80
97
|
});
|
|
98
|
+
test("generateSystemdUnit PATH includes Linuxbrew and toolchain directories", () => {
|
|
99
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX, shellPath: "" });
|
|
100
|
+
assert.ok(unit.includes("/home/linuxbrew/.linuxbrew/bin"), "unit PATH should include Linuxbrew");
|
|
101
|
+
assert.ok(unit.includes("%h/.cargo/bin"), "unit PATH should include %h/.cargo/bin");
|
|
102
|
+
assert.ok(unit.includes("%h/.bun/bin"), "unit PATH should include %h/.bun/bin");
|
|
103
|
+
assert.ok(unit.includes("%h/.volta/bin"), "unit PATH should include %h/.volta/bin");
|
|
104
|
+
assert.ok(unit.includes("%h/.local/bin"), "unit PATH should include %h/.local/bin");
|
|
105
|
+
assert.ok(unit.includes("/opt/homebrew/bin"), "unit PATH should include /opt/homebrew/bin");
|
|
106
|
+
});
|
|
107
|
+
test("generateSystemdUnit PATH prepends installing shell PATH", () => {
|
|
108
|
+
const shellPath = "/custom/shell/bin";
|
|
109
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX, shellPath });
|
|
110
|
+
assert.ok(unit.includes(shellPath), "unit PATH should include the installing shell PATH");
|
|
111
|
+
});
|
|
112
|
+
test("generateSystemdUnit includes TimeoutStopSec=90", () => {
|
|
113
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
114
|
+
assert.ok(unit.includes("TimeoutStopSec=90"), "unit should set TimeoutStopSec=90 for timing contract");
|
|
115
|
+
});
|
|
81
116
|
test("generateSystemdUnit has all three sections", () => {
|
|
82
117
|
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
83
118
|
assert.ok(unit.includes("[Unit]"), "unit should have [Unit] section");
|
package/dist/daemon.js
CHANGED
|
@@ -19,6 +19,23 @@ import { registerShutdownSignals } from "./shutdown-signals.js";
|
|
|
19
19
|
import { logger } from "./util/logger.js";
|
|
20
20
|
const log = logger.child({ module: "daemon" });
|
|
21
21
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
/**
|
|
23
|
+
* How long the daemon waits for in-flight work to finish before forcing an exit.
|
|
24
|
+
* Layer 2 of the 3-layer timing contract:
|
|
25
|
+
* Layer 1 — orchestrator turn: CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS (default 1800 s)
|
|
26
|
+
* Layer 2 — daemon shutdown: CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS (default 60 s) ← this
|
|
27
|
+
* Layer 3 — systemd kill: TimeoutStopSec=90 s (must exceed layer 2)
|
|
28
|
+
* Allows in-flight LLM streams to complete before the process is torn down.
|
|
29
|
+
*/
|
|
30
|
+
const SHUTDOWN_TIMEOUT_MS = (() => {
|
|
31
|
+
const env = process.env.CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS;
|
|
32
|
+
if (env) {
|
|
33
|
+
const parsed = parseInt(env, 10);
|
|
34
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
return 60_000;
|
|
38
|
+
})();
|
|
22
39
|
/** Remove orphaned session folders older than 7 days, preserving the current session. */
|
|
23
40
|
function pruneOldSessions() {
|
|
24
41
|
try {
|
|
@@ -165,11 +182,11 @@ async function shutdown() {
|
|
|
165
182
|
}
|
|
166
183
|
shutdownState = "shutting_down";
|
|
167
184
|
log.info("Shutting down (Ctrl+C again to force)");
|
|
168
|
-
// Force exit after
|
|
185
|
+
// Force exit after the configured grace period no matter what
|
|
169
186
|
const forceTimer = setTimeout(() => {
|
|
170
187
|
log.warn("Shutdown timed out — forcing exit");
|
|
171
188
|
process.exit(1);
|
|
172
|
-
},
|
|
189
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
173
190
|
forceTimer.unref();
|
|
174
191
|
// Destroy all active agent sessions
|
|
175
192
|
await shutdownAgents();
|
package/package.json
CHANGED