teapot-coding-agent 0.26.13 → 0.27.0
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 -3
- package/dist/agent/agent.js +85 -14
- package/dist/agent/kill.js +70 -0
- package/dist/agent/skills.js +4 -1
- package/dist/agent/tools.js +23 -22
- package/dist/home.js +51 -0
- package/dist/log/events.js +41 -7
- package/dist/master.js +35 -1
- package/dist/server/api.js +51 -27
- package/package.json +3 -2
- package/public/assets/index-B7AL8hNs.css +1 -0
- package/public/assets/{index-B_BBXIta.js → index-CaKIcOkZ.js} +19 -19
- package/public/assets/index-CaKIcOkZ.js.map +1 -0
- package/public/assets/{shiki-gZkMv9DA.js → shiki-B6zAhgc_.js} +2 -2
- package/public/assets/{shiki-gZkMv9DA.js.map → shiki-B6zAhgc_.js.map} +1 -1
- package/public/index.html +2 -2
- package/README.ja.md +0 -243
- package/public/assets/index-B_BBXIta.js.map +0 -1
- package/public/assets/index-DBx_UTjS.css +0 -1
package/README.md
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://deepwiki.com/akku1139/teapot)
|
|
8
8
|
|
|
9
|
-
[日本語版はこちら](README.ja.md)
|
|
10
|
-
|
|
11
9
|
---
|
|
12
10
|
|
|
13
11
|
## 🚀 Quick start
|
|
@@ -167,7 +165,7 @@ Agents can run `bash` and rewrite files — including `rm` and `npm install`.
|
|
|
167
165
|
|
|
168
166
|
- Designed to run under a **dedicated Linux user**
|
|
169
167
|
- `git commit` before handing over a repo you care about
|
|
170
|
-
- File operations outside the workspace are rejected (`safeJoin`)
|
|
168
|
+
- File operations outside the workspace are rejected (`safeJoin`), but agents can use `bash` outside of the worktree
|
|
171
169
|
- teapot listens on localhost only by default. If you expose it, set **both** `--host 0.0.0.0` and an API token (`TEAPOT_API_TOKEN` env or config `password`):
|
|
172
170
|
|
|
173
171
|
```sh
|
package/dist/agent/agent.js
CHANGED
|
@@ -1227,23 +1227,48 @@ export class Agent {
|
|
|
1227
1227
|
}
|
|
1228
1228
|
if (p.source === "user") {
|
|
1229
1229
|
const id = p.id ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
1230
|
-
|
|
1231
|
-
//
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
void this.log
|
|
1235
|
-
.append("system_note", this.currentSession, this.currentBranch, {
|
|
1236
|
-
event: "prompt-delivered",
|
|
1237
|
-
promptId: id,
|
|
1238
|
-
preview: p.text.slice(0, 80),
|
|
1239
|
-
})
|
|
1240
|
-
.then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
|
|
1241
|
-
.catch(() => { });
|
|
1230
|
+
// #37: queued here, MARKED at the request boundary instead — see
|
|
1231
|
+
// markDeliveredPrompts(). Text is kept so the marker can carry the same
|
|
1232
|
+
// preview the UI shows.
|
|
1233
|
+
this.undeliveredPrompts.push({ id, preview: p.text.slice(0, 80) });
|
|
1242
1234
|
}
|
|
1243
1235
|
}
|
|
1244
1236
|
}
|
|
1245
1237
|
/** ids of user prompts that have entered an LLM call payload (recent first-capped) */
|
|
1246
1238
|
deliveredPrompts = [];
|
|
1239
|
+
/**
|
|
1240
|
+
* User prompts consumed from the queue but not yet MARKED as delivered (#37).
|
|
1241
|
+
*
|
|
1242
|
+
* They are drained at a turn boundary, but the `prompt-delivered` marker must
|
|
1243
|
+
* wait until the request that carries them actually starts — which is later,
|
|
1244
|
+
* and after `maybeCompact()`, which can itself call the LLM.
|
|
1245
|
+
*/
|
|
1246
|
+
undeliveredPrompts = [];
|
|
1247
|
+
/**
|
|
1248
|
+
* Log `prompt-delivered` for every prompt whose outbound request is starting.
|
|
1249
|
+
*
|
|
1250
|
+
* #37: the marker's meaning is "the request carrying this prompt has begun".
|
|
1251
|
+
* It used to fire in drainPendingPrompts, i.e. before the request, so the
|
|
1252
|
+
* timeline reordered by a moment the API never saw.
|
|
1253
|
+
*/
|
|
1254
|
+
async markDeliveredPrompts() {
|
|
1255
|
+
if (this.undeliveredPrompts.length === 0)
|
|
1256
|
+
return;
|
|
1257
|
+
const pending = this.undeliveredPrompts;
|
|
1258
|
+
this.undeliveredPrompts = [];
|
|
1259
|
+
for (const { id, preview } of pending) {
|
|
1260
|
+
this.deliveredPrompts.push(id);
|
|
1261
|
+
// cap: the UI only needs recent confirmations
|
|
1262
|
+
if (this.deliveredPrompts.length > 64)
|
|
1263
|
+
this.deliveredPrompts.shift();
|
|
1264
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
1265
|
+
event: "prompt-delivered",
|
|
1266
|
+
promptId: id,
|
|
1267
|
+
preview,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
1271
|
+
}
|
|
1247
1272
|
/**
|
|
1248
1273
|
* Background jobs that exited since the last LLM call. Their outcomes are
|
|
1249
1274
|
* folded into ONE harness message at the turn boundary — the agent learns
|
|
@@ -1366,9 +1391,25 @@ export class Agent {
|
|
|
1366
1391
|
stopSeq = 0;
|
|
1367
1392
|
/** #95: set when this round already injected a harness progress request */
|
|
1368
1393
|
progressRequestedThisRound = false;
|
|
1394
|
+
/**
|
|
1395
|
+
* CONTROL PLANE: fired on every status change, immediately, independent of the
|
|
1396
|
+
* log.
|
|
1397
|
+
*
|
|
1398
|
+
* #126: `wait_children` wakeups used to ride `EventLog.onEvent`, so a status
|
|
1399
|
+
* change reached the parent only AFTER the write completed. After the
|
|
1400
|
+
* write-before-notify fix that meant a FAILED or slow write left a parked parent
|
|
1401
|
+
* waiting forever — persistence latency became control flow. Measured with every
|
|
1402
|
+
* write failing: `onEvent fired: 0`, so no wakeup at all.
|
|
1403
|
+
*
|
|
1404
|
+
* Control flow must not depend on durability, so this is separate from the
|
|
1405
|
+
* event pipeline. The log's `onEvent` now means "persisted, tell the world";
|
|
1406
|
+
* this means "my state changed, react now".
|
|
1407
|
+
*/
|
|
1408
|
+
onControlStatusChange = null;
|
|
1369
1409
|
setStatus(s, reason = "") {
|
|
1370
|
-
// Update the field FIRST.
|
|
1371
|
-
//
|
|
1410
|
+
// Update the field FIRST. The control-plane hook fires synchronously below
|
|
1411
|
+
// (see onControlStatusChange), and it is what wakes parked wait_children
|
|
1412
|
+
// callers — those
|
|
1372
1413
|
// re-check agent.status at that instant. Updating after the append made
|
|
1373
1414
|
// every waiter observe the OLD status ("running"), conclude "someone is
|
|
1374
1415
|
// still working", and stay parked until timeout even though the child had
|
|
@@ -1377,6 +1418,15 @@ export class Agent {
|
|
|
1377
1418
|
this.status = s;
|
|
1378
1419
|
this.statusReason = reason;
|
|
1379
1420
|
if (prev !== s) {
|
|
1421
|
+
// #126: control plane FIRST, and not via the log. A parent's wait_children
|
|
1422
|
+
// must wake on the status change itself, not on the durability of the write
|
|
1423
|
+
// that records it.
|
|
1424
|
+
try {
|
|
1425
|
+
this.onControlStatusChange?.(this.opts.id, s);
|
|
1426
|
+
}
|
|
1427
|
+
catch {
|
|
1428
|
+
/* a control-plane listener must never break a status change */
|
|
1429
|
+
}
|
|
1380
1430
|
void this.log.append("state", this.currentSession, this.currentBranch, {
|
|
1381
1431
|
from: prev,
|
|
1382
1432
|
to: s,
|
|
@@ -1591,6 +1641,27 @@ export class Agent {
|
|
|
1591
1641
|
text: "",
|
|
1592
1642
|
reasoning: "",
|
|
1593
1643
|
});
|
|
1644
|
+
// #37: `prompt-delivered` must mean "the outbound request CARRYING this
|
|
1645
|
+
// prompt has started", not "the prompt moved from the queue into
|
|
1646
|
+
// `this.messages`".
|
|
1647
|
+
//
|
|
1648
|
+
// It used to be logged in drainPendingPrompts, which runs BEFORE
|
|
1649
|
+
// refreshSkills, maybeRequestProgress and — critically — maybeCompact. So
|
|
1650
|
+
// the marker preceded the request, and `maybeCompact()` can itself make an
|
|
1651
|
+
// LLM call, which meant the timeline claimed a delivery order the API never
|
|
1652
|
+
// saw. Measured before the fix:
|
|
1653
|
+
//
|
|
1654
|
+
// LLM CALL #1
|
|
1655
|
+
// seq=5 prompt-delivered("first prompt")
|
|
1656
|
+
// seq=6 llm turn start
|
|
1657
|
+
//
|
|
1658
|
+
// The UI reorders by this marker (resequenceToDelivery), so the semantics
|
|
1659
|
+
// were the bug — not timeline-order.ts.
|
|
1660
|
+
//
|
|
1661
|
+
// Emitted ONCE per request, not per attempt: a retry sends the same payload,
|
|
1662
|
+
// so re-emitting would move the marker after a later event on each try.
|
|
1663
|
+
if (attempt === 1)
|
|
1664
|
+
await this.markDeliveredPrompts();
|
|
1594
1665
|
try {
|
|
1595
1666
|
return await this.callLlm(messages, tools, onDelta);
|
|
1596
1667
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** can this platform kill a whole process group? */
|
|
2
|
+
export function canKillProcessGroup(platform = process.platform) {
|
|
3
|
+
return platform !== "win32";
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Kill a spawned child, and its whole group where the platform allows it.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately does NOT throw: callers are timeouts and abort handlers, where a
|
|
9
|
+
* throw would replace the real message with a secondary failure.
|
|
10
|
+
*/
|
|
11
|
+
export function killTree(child, platform = process.platform) {
|
|
12
|
+
if (!child?.pid)
|
|
13
|
+
return { killed: false, wholeTree: false, reason: "no-pid" };
|
|
14
|
+
if (!canKillProcessGroup(platform)) {
|
|
15
|
+
// Windows: no group kill. Try the direct child so at least THAT stops, and
|
|
16
|
+
// report that the tree is not covered rather than implying it is.
|
|
17
|
+
try {
|
|
18
|
+
process.kill(child.pid, "SIGKILL");
|
|
19
|
+
return { killed: true, wholeTree: false };
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const code = err.code;
|
|
23
|
+
return {
|
|
24
|
+
killed: false,
|
|
25
|
+
wholeTree: false,
|
|
26
|
+
reason: code === "ESRCH" ? "already-gone" : "error",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
process.kill(-child.pid, "SIGKILL"); // whole group
|
|
32
|
+
return { killed: true, wholeTree: true };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const code = err.code;
|
|
36
|
+
// EPERM on the group can still mean the child is killable directly, and
|
|
37
|
+
// EPERM is what a partially-privileged tree produces.
|
|
38
|
+
if (code === "EPERM") {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(child.pid, "SIGKILL");
|
|
41
|
+
return { killed: true, wholeTree: false };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { killed: false, wholeTree: false, reason: "error" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (code === "ESRCH")
|
|
48
|
+
return { killed: false, wholeTree: false, reason: "already-gone" };
|
|
49
|
+
return { killed: false, wholeTree: false, reason: "error" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** a sentence for the tool result, so a partial kill is never silent */
|
|
53
|
+
export function describeKill(outcome) {
|
|
54
|
+
if (outcome.killed && outcome.wholeTree)
|
|
55
|
+
return "killed (whole process group)";
|
|
56
|
+
if (outcome.killed) {
|
|
57
|
+
return ("killed the process, but this platform has no process groups — any " +
|
|
58
|
+
"background children it started may still be running");
|
|
59
|
+
}
|
|
60
|
+
switch (outcome.reason) {
|
|
61
|
+
case "no-pid":
|
|
62
|
+
return "no process handle to kill";
|
|
63
|
+
case "already-gone":
|
|
64
|
+
return "already exited";
|
|
65
|
+
case "group-unsupported":
|
|
66
|
+
return "process groups are not supported on this platform";
|
|
67
|
+
default:
|
|
68
|
+
return "could not be killed";
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/agent/skills.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { promises as fs } from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
|
+
import { homeDir } from "../home.js";
|
|
21
22
|
export const SKILL_FILE = "SKILL.md";
|
|
22
23
|
/**
|
|
23
24
|
* Cheap change-detector for a set of skill roots. Returns a stable string
|
|
@@ -148,7 +149,9 @@ function unquote(v) {
|
|
|
148
149
|
* directories are skipped silently, and nothing is read unless it exists.
|
|
149
150
|
*/
|
|
150
151
|
export function foreignSkillRoots(workspace) {
|
|
151
|
-
|
|
152
|
+
// #110: the shared helper also covers HOMEDRIVE/HOMEPATH, which is how
|
|
153
|
+
// Windows reports a home when USERPROFILE is absent.
|
|
154
|
+
const home = homeDir();
|
|
152
155
|
const out = [];
|
|
153
156
|
const add = (dir) => {
|
|
154
157
|
if (dir && !out.some((r) => r.dir === dir))
|
package/dist/agent/tools.js
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, promises as fs, readlinkSync, realpathSync } from "node:fs"
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
|
|
9
9
|
import { isReasoningEffort } from "../model-meta.js";
|
|
10
|
+
import { killTree, describeKill } from "./kill.js";
|
|
10
11
|
const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
11
12
|
const num = (v, fallback) => (typeof v === "number" ? v : fallback);
|
|
12
13
|
function clip(s, max) {
|
|
@@ -322,23 +323,18 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
322
323
|
};
|
|
323
324
|
child.stdout.on("data", collect);
|
|
324
325
|
child.stderr.on("data", collect);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
/* already gone */
|
|
332
|
-
}
|
|
333
|
-
};
|
|
326
|
+
// #110: the kill REPORTS whether it worked. It used to swallow every failure
|
|
327
|
+
// as "already gone", and on Windows a group kill throws ALWAYS — so a timeout
|
|
328
|
+
// would report `TIMEOUT after …ms` for a process that was still running, and
|
|
329
|
+
// shutdown would leave it alive. A timeout that claims success is worse than
|
|
330
|
+
// a crash, because nothing afterwards looks wrong.
|
|
331
|
+
const killGroup = () => killTree(child);
|
|
334
332
|
const timer = setTimeout(() => {
|
|
335
|
-
killReason = `TIMEOUT after ${timeoutMs}ms`;
|
|
336
|
-
killGroup();
|
|
333
|
+
killReason = `TIMEOUT after ${timeoutMs}ms — ${describeKill(killGroup())}`;
|
|
337
334
|
}, timeoutMs);
|
|
338
335
|
// harness shutdown must not wait out a long-running command
|
|
339
336
|
const onAbort = () => {
|
|
340
|
-
killReason =
|
|
341
|
-
killGroup();
|
|
337
|
+
killReason = `ABORTED (harness shutdown) — ${describeKill(killGroup())}`;
|
|
342
338
|
};
|
|
343
339
|
if (ctx.signal) {
|
|
344
340
|
if (ctx.signal.aborted)
|
|
@@ -1196,8 +1192,18 @@ export const TOOLS = [
|
|
|
1196
1192
|
return stale;
|
|
1197
1193
|
let oldText = str(args.old_text);
|
|
1198
1194
|
const newText = str(args.new_text);
|
|
1195
|
+
// #126: the single most common edit_file failure in the real logs (22
|
|
1196
|
+
// occurrences) said only "old_text is required", while the tool's own
|
|
1197
|
+
// schema already documents the better form. Name it and say what to do —
|
|
1198
|
+
// the pattern master.ts uses for "not your sub-agent" (master.ts:979),
|
|
1199
|
+
// which turned a confusing refusal into a re-addressable one.
|
|
1199
1200
|
if (!oldText)
|
|
1200
|
-
return {
|
|
1201
|
+
return {
|
|
1202
|
+
ok: false,
|
|
1203
|
+
result: "old_text is required — pass the exact text to replace, or read with " +
|
|
1204
|
+
'`read_file({line_ids:"hash"})` and pass that block VERBATIM as old_text. ' +
|
|
1205
|
+
"Do NOT copy read_file's `N| ` line-number prefixes.",
|
|
1206
|
+
};
|
|
1201
1207
|
const replaceAll = args.replace_all === true;
|
|
1202
1208
|
// #7/#4: accept a block copied straight out of read_file's hashline
|
|
1203
1209
|
// output. When the anchors do not verify we FAIL with the specific reason
|
|
@@ -1422,14 +1428,9 @@ export const TOOLS = [
|
|
|
1422
1428
|
`jobs are per-workspace and forgotten on harness restart`,
|
|
1423
1429
|
};
|
|
1424
1430
|
if (str(args.action) === "kill") {
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
process.kill(-sh.child.pid, "SIGKILL");
|
|
1429
|
-
}
|
|
1430
|
-
catch { /* already gone */ }
|
|
1431
|
-
}
|
|
1432
|
-
const note = sh.exited ? "(already exited)" : "killed";
|
|
1431
|
+
// #110: same swallowed-kill bug — this reported "killed" and dropped the
|
|
1432
|
+
// job from the map while the process kept running, now untrackable.
|
|
1433
|
+
const note = sh.exited ? "(already exited)" : describeKill(killTree(sh.child));
|
|
1433
1434
|
map.delete(sh.id);
|
|
1434
1435
|
return { ok: true, result: `job ${sh.id} ${note}. Output so far:\n${clip(sh.out, 4000)}` };
|
|
1435
1436
|
}
|
package/dist/home.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's home directory, on every platform.
|
|
3
|
+
*
|
|
4
|
+
* #110: `process.env.HOME` is a POSIX convention and is normally UNSET on
|
|
5
|
+
* Windows. Code that expanded `~` with `process.env.HOME ?? "~"` therefore got
|
|
6
|
+
* the LITERAL string "~" back on Windows — and since the result was then passed
|
|
7
|
+
* to `path.resolve(base, …)` and `mkdirSync`, teapot created a directory named
|
|
8
|
+
* `~`. That is the reported symptom.
|
|
9
|
+
*
|
|
10
|
+
* `os.homedir()` is the answer: on Windows Node resolves it from `USERPROFILE`,
|
|
11
|
+
* on POSIX from `HOME`, so it is never the literal `~`. The env lookups are kept
|
|
12
|
+
* only so a test can drive the Windows shape deterministically — `os.homedir()`
|
|
13
|
+
* on a Linux CI box would report `/root` and hide the very condition under test.
|
|
14
|
+
*
|
|
15
|
+
* Returns "" only when nothing at all is known, which callers treat as "leave the
|
|
16
|
+
* `~` alone" rather than inventing a path.
|
|
17
|
+
*/
|
|
18
|
+
export function homeDir(env = process.env) {
|
|
19
|
+
if (env.HOME)
|
|
20
|
+
return env.HOME;
|
|
21
|
+
if (env.USERPROFILE)
|
|
22
|
+
return env.USERPROFILE;
|
|
23
|
+
// Windows also reports a home as HOMEDRIVE + HOMEPATH. Concatenate
|
|
24
|
+
// defensively: `drive.replace(...) + path` yields the STRING "undefined"
|
|
25
|
+
// rather than undefined when either part is missing, which is exactly the
|
|
26
|
+
// class of bug this helper exists to remove.
|
|
27
|
+
const drive = env.HOMEDRIVE;
|
|
28
|
+
const rest = env.HOMEPATH;
|
|
29
|
+
if (drive && rest)
|
|
30
|
+
return drive.replace(/\\+$/, "") + rest;
|
|
31
|
+
if (drive)
|
|
32
|
+
return drive;
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Expand a leading `~` to the home directory.
|
|
37
|
+
*
|
|
38
|
+
* Kept in one place so the Windows case cannot be got wrong a second time — the
|
|
39
|
+
* bug existed in three separate places before this helper (#110).
|
|
40
|
+
*/
|
|
41
|
+
export function expandTilde(input, env = process.env) {
|
|
42
|
+
// #110: on Windows a path can be `~\foo`, hence the backslash in the lookahead.
|
|
43
|
+
if (!/^~(?=$|[/\\])/.test(input))
|
|
44
|
+
return input;
|
|
45
|
+
const home = homeDir(env);
|
|
46
|
+
// With no home known, returning the input untouched is more honest than
|
|
47
|
+
// substituting a literal that would become a real directory.
|
|
48
|
+
if (!home)
|
|
49
|
+
return input;
|
|
50
|
+
return input.replace(/^~(?=$|[/\\])/, home);
|
|
51
|
+
}
|
package/dist/log/events.js
CHANGED
|
@@ -89,12 +89,25 @@ export class EventLog {
|
|
|
89
89
|
data,
|
|
90
90
|
};
|
|
91
91
|
this.lastByBranch.set(branch, evt.id);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
// #54: WRITE FIRST, then notify.
|
|
93
|
+
//
|
|
94
|
+
// The observer used to fire BEFORE the write. The frontend receives a
|
|
95
|
+
// `kind:"event"` frame and — by design — treats it as "something changed"
|
|
96
|
+
// and re-reads `/events`. So the reload could read the log BEFORE this
|
|
97
|
+
// event reached disk, and the reload would simply not contain it.
|
|
98
|
+
//
|
|
99
|
+
// Measured against a real EventLog, reloading on every notification:
|
|
100
|
+
//
|
|
101
|
+
// appends: 30 WS-triggered reloads that MISSED the event: 30
|
|
102
|
+
//
|
|
103
|
+
// The 120ms debounce usually hides this, which is exactly why #54 felt
|
|
104
|
+
// random: a slow write misses, a fast one lands. Notifying after the write
|
|
105
|
+
// makes the event visible to any reader the observer triggers.
|
|
106
|
+
// `written` distinguishes a REAL write from the post-close drop path, which
|
|
107
|
+
// also resolves. #54: announcing a dropped event would tell the frontend to
|
|
108
|
+
// reload for something that will never appear — the row would flicker in and
|
|
109
|
+
// straight back out.
|
|
110
|
+
let written = false;
|
|
98
111
|
const p = new Promise((resolve, reject) => {
|
|
99
112
|
this.chain = this.chain.then(() => {
|
|
100
113
|
if (!this.stream) {
|
|
@@ -103,10 +116,31 @@ export class EventLog {
|
|
|
103
116
|
console.error(`[teapot] event dropped after log close (${this.agentId}): ${type}`);
|
|
104
117
|
return resolve(evt);
|
|
105
118
|
}
|
|
106
|
-
this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) =>
|
|
119
|
+
this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => {
|
|
120
|
+
if (err)
|
|
121
|
+
return reject(err);
|
|
122
|
+
written = true;
|
|
123
|
+
resolve(evt);
|
|
124
|
+
});
|
|
107
125
|
}, () => resolve(evt));
|
|
108
126
|
});
|
|
109
127
|
this.chain = this.chain.then(() => { }, () => { });
|
|
128
|
+
// notify once the write is handed to the OS, so a reload the observer
|
|
129
|
+
// triggers can actually see this event
|
|
130
|
+
void p
|
|
131
|
+
.then(() => {
|
|
132
|
+
if (!written)
|
|
133
|
+
return; // dropped after close: nothing on disk to announce
|
|
134
|
+
try {
|
|
135
|
+
this.onEvent?.(evt);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* observer must never break the log */
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
.catch(() => {
|
|
142
|
+
/* a failed write must not notify — the event is not on disk */
|
|
143
|
+
});
|
|
110
144
|
return p;
|
|
111
145
|
}
|
|
112
146
|
lastEventId(branch) {
|
package/dist/master.js
CHANGED
|
@@ -7,6 +7,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSy
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import os from "node:os";
|
|
10
|
+
import { expandTilde } from "./home.js";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { spawn } from "node:child_process";
|
|
12
13
|
import { Agent } from "./agent/agent.js";
|
|
@@ -64,7 +65,9 @@ function readAppVersion() {
|
|
|
64
65
|
* `~` is expanded for both forms; absolute paths pass through untouched.
|
|
65
66
|
*/
|
|
66
67
|
export function resolveWorkspace(input, base) {
|
|
67
|
-
|
|
68
|
+
// #110: `~` must not expand to the LITERAL "~" — that is how a directory named
|
|
69
|
+
// `~` got created on Windows, where process.env.HOME is unset. See src/home.ts.
|
|
70
|
+
const expanded = expandTilde(input.trim());
|
|
68
71
|
if (path.isAbsolute(expanded))
|
|
69
72
|
return path.normalize(expanded);
|
|
70
73
|
return path.resolve(base, expanded);
|
|
@@ -521,6 +524,28 @@ export class Master {
|
|
|
521
524
|
bus.emit("update", { kind: "event", agentId: e.agent, event: e });
|
|
522
525
|
this.onChildEvent(ac, e);
|
|
523
526
|
};
|
|
527
|
+
// #126: the CONTROL PLANE, separate from the event pipeline above.
|
|
528
|
+
//
|
|
529
|
+
// `onChildEvent` used to be what woke a parent's `wait_children`, and it runs
|
|
530
|
+
// after the event is persisted. So a failed or slow write left the parent
|
|
531
|
+
// parked forever — measured with every write failing, the wake never fired.
|
|
532
|
+
//
|
|
533
|
+
// A status change is CONTROL FLOW, not an observation, so it gets its own
|
|
534
|
+
// immediate path. `onChildEvent` keeps its other job (mirroring the child's
|
|
535
|
+
// activity into the parent's timeline, forwarding its final report), which
|
|
536
|
+
// legitimately belongs to the event stream.
|
|
537
|
+
agent.onControlStatusChange = (agentId, status) => {
|
|
538
|
+
if (!ac.parent)
|
|
539
|
+
return;
|
|
540
|
+
const parent = this.agents.get(ac.parent);
|
|
541
|
+
if (!parent)
|
|
542
|
+
return;
|
|
543
|
+
// the waiter re-checks whether ITS targets are all settled, so waking on
|
|
544
|
+
// every movement is correct and cheap
|
|
545
|
+
this.wakeParentWaiters(ac.parent);
|
|
546
|
+
if (status === "idle" || status === "stopped" || status === "waiting" || status === "error")
|
|
547
|
+
parent.enqueuePrompt(`[harness] @${agentId} is now ${status}. Continue your task, or finish() if the work is done.`, "harness");
|
|
548
|
+
};
|
|
524
549
|
// sub-agent management hooks — only when this agent can legally spawn
|
|
525
550
|
if (myDepth < this.maxSpawnDepth()) {
|
|
526
551
|
const self = agent;
|
|
@@ -839,9 +864,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
839
864
|
// silently dropped exactly the details the operator spawned the sub
|
|
840
865
|
// to gather. Context management is compaction's job, not the pipe's.
|
|
841
866
|
`[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
|
|
867
|
+
// #123: a parent's report is delivered INTO a live agent — queueing it is
|
|
868
|
+
// not enough. If the parent is idle (finished, or parked) the report sat in
|
|
869
|
+
// the queue until something else started the loop, so the operator saw a
|
|
870
|
+
// finished sub-agent and a parent that never reacted.
|
|
871
|
+
if (parent.status !== "running")
|
|
872
|
+
parent.start(`sub-agent ${ac.id} finished`);
|
|
842
873
|
}
|
|
843
874
|
else if (e.type === "error") {
|
|
844
875
|
parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
|
|
876
|
+
// #123: same for an error report
|
|
877
|
+
if (parent.status !== "running")
|
|
878
|
+
parent.start(`sub-agent ${ac.id} error`);
|
|
845
879
|
}
|
|
846
880
|
// #63: a child's activity is NOT mirrored into the parent's log.
|
|
847
881
|
//
|