teapot-coding-agent 0.29.6 → 0.29.8
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/dist/acp/adapter.js +26 -49
- package/dist/agent/agent.js +7 -18
- package/dist/agent/llm.js +29 -58
- package/dist/agent/skills.js +20 -31
- package/dist/agent/tools.js +9 -4
- package/dist/config-schema.js +4 -4
- package/dist/home.js +15 -27
- package/dist/index.js +31 -68
- package/dist/master.js +154 -67
- package/dist/scheduler/cron.js +5 -6
- package/dist/server/term-size.js +2 -3
- package/package.json +1 -1
- package/public/assets/{index-WARWw8ga.js → index-CzRDDcrd.js} +5 -5
- package/public/assets/index-CzRDDcrd.js.map +1 -0
- package/public/assets/{shiki-BPGMQVPH.js → shiki-D5AInxu8.js} +2 -2
- package/public/assets/{shiki-BPGMQVPH.js.map → shiki-D5AInxu8.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-WARWw8ga.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -37,15 +37,6 @@ async function waitForPortFree(port, host, timeoutMs) {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
/* ---------- live-update handover: keeping the operator's terminal (#35) ---------- */
|
|
40
|
-
/**
|
|
41
|
-
* The pid of the process that replaced US, or null if we are the server.
|
|
42
|
-
*
|
|
43
|
-
* `Master.restartServer()` spawns the replacement and records its pid here, so
|
|
44
|
-
* the outgoing process can supervise its own successor instead of exiting and
|
|
45
|
-
* handing the shell a prompt. A field on the master rather than an env var: both
|
|
46
|
-
* processes are separate, but this value is written and read in the SAME
|
|
47
|
-
* process, so there is no environment for the two of them to disagree over.
|
|
48
|
-
*/
|
|
49
40
|
/** is `pid` still running? (EPERM means alive but not ours to signal) */
|
|
50
41
|
function pidAlive(pid) {
|
|
51
42
|
try {
|
|
@@ -60,16 +51,13 @@ function pidAlive(pid) {
|
|
|
60
51
|
* Watch the replacement on the operator's behalf.
|
|
61
52
|
*
|
|
62
53
|
* The terminal is still wired to THIS process, so their Ctrl-C arrives here
|
|
63
|
-
* first
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* 1. forward the signal, so the replacement actually stops; and
|
|
67
|
-
* 2. when the replacement does exit, leave too — otherwise the shell waits on
|
|
68
|
-
* a supervisor with nothing left to supervise and Ctrl-C looks broken.
|
|
54
|
+
* first: forward the signal so the replacement actually stops, and leave once
|
|
55
|
+
* it exits — otherwise the shell waits on a supervisor with nothing left to
|
|
56
|
+
* supervise and Ctrl-C looks broken.
|
|
69
57
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
58
|
+
* shutdown() cannot be reused for this: its `shuttingDown` latch is already set
|
|
59
|
+
* by the SIGTERM that started the handover, so a later Ctrl-C would be swallowed
|
|
60
|
+
* and this process would never exit.
|
|
73
61
|
*/
|
|
74
62
|
function supervise(pid) {
|
|
75
63
|
let signalled = false;
|
|
@@ -94,11 +82,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
94
82
|
/**
|
|
95
83
|
* Parse `teapot [--port N] [-p N] [--host addr] [--config file] [config.json]`.
|
|
96
84
|
*
|
|
97
|
-
*
|
|
98
|
-
* which
|
|
99
|
-
*
|
|
100
|
-
* regression here starts the wrong server on the wrong port with no error anyone
|
|
101
|
-
* would read.
|
|
85
|
+
* Exported so it can be tested: the parser decides which config file is read and
|
|
86
|
+
* which port is bound, so a regression here starts the wrong server on the wrong
|
|
87
|
+
* port with no error anyone would read.
|
|
102
88
|
*
|
|
103
89
|
* The bare-word rule keeps the FIRST positional as the config path: a later bare
|
|
104
90
|
* word is a stray argument, not an override, and silently replacing the config
|
|
@@ -160,18 +146,13 @@ async function main() {
|
|
|
160
146
|
if (!Number.isNaN(fromPid) && fromPid !== process.pid) {
|
|
161
147
|
console.log(`[teapot] restart: releasing port from old process ${fromPid}...`);
|
|
162
148
|
try {
|
|
163
|
-
// Flush the old process's agents and let it release the socket. It
|
|
164
|
-
// NOT wait for us
|
|
165
|
-
// lingers only long enough for its own HTTP response to flush, then exits.
|
|
149
|
+
// Flush the old process's agents and let it release the socket. It must
|
|
150
|
+
// NOT wait for us — that would deadlock against our bind below.
|
|
166
151
|
process.kill(fromPid, "SIGTERM");
|
|
167
152
|
}
|
|
168
153
|
catch {
|
|
169
154
|
/* already gone */
|
|
170
155
|
}
|
|
171
|
-
// Wait until the socket is genuinely free. Binding while the old process
|
|
172
|
-
// still holds it fails with EADDRINUSE, and because index.ts swallows
|
|
173
|
-
// uncaught exceptions that left two processes running while neither served
|
|
174
|
-
// anything (#35).
|
|
175
156
|
if (!(await waitForPortFree(config.port, config.host, 30_000)))
|
|
176
157
|
console.warn(`[teapot] warning: could not confirm ${config.host}:${config.port} is free — binding anyway`);
|
|
177
158
|
}
|
|
@@ -209,32 +190,22 @@ async function main() {
|
|
|
209
190
|
if (shuttingDown)
|
|
210
191
|
return;
|
|
211
192
|
shuttingDown = true;
|
|
212
|
-
// SUPERVISOR MODE. When a live update replaced us
|
|
213
|
-
// server
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
// to the replacement, and only leave once it is gone. The operator's
|
|
221
|
-
// terminal — and their Ctrl-C — follow the server across the restart.
|
|
222
|
-
//
|
|
223
|
-
// The replacement is the child we spawned ourselves, so we hold it directly:
|
|
224
|
-
// no pid passed through the environment, and no way for the two processes
|
|
225
|
-
// to disagree about who supervises whom.
|
|
193
|
+
// SUPERVISOR MODE. When a live update replaced us we are no longer the
|
|
194
|
+
// server, but we are still the process the SHELL waits on and the one that
|
|
195
|
+
// owns the terminal; exiting here dumped the operator back to the prompt
|
|
196
|
+
// mid-session while the new server kept running in the background (#35). So
|
|
197
|
+
// we keep the job slot, forward signals to the replacement, and leave only
|
|
198
|
+
// once it is gone. The replacement is the child we spawned ourselves, so we
|
|
199
|
+
// hold it directly — no pid through the environment for the two processes to
|
|
200
|
+
// disagree over.
|
|
226
201
|
if (master.replacementPid !== null) {
|
|
227
202
|
const replacement = master.replacementPid;
|
|
228
203
|
console.log(`[teapot] handing the terminal to the new server (pid ${replacement})...`);
|
|
229
|
-
// Release the port FIRST
|
|
230
|
-
// waiting for exactly this, and it will SIGTERM us again if we do not go
|
|
231
|
-
// — but the difference now is that we stop SERVING rather than exiting,
|
|
232
|
-
// so the shell's foreground job survives the handover.
|
|
204
|
+
// Release the port FIRST: the replacement is blocked in waitForPortFree
|
|
205
|
+
// waiting for exactly this, and it will SIGTERM us again if we do not go.
|
|
233
206
|
await server.close();
|
|
234
207
|
console.log("[teapot] port released; supervising the new server");
|
|
235
208
|
supervise(replacement);
|
|
236
|
-
// Stay attached until the replacement exits. The shell is still waiting
|
|
237
|
-
// on US, so leaving early is exactly the bug.
|
|
238
209
|
while (pidAlive(replacement))
|
|
239
210
|
await sleep(500);
|
|
240
211
|
console.log("[teapot] new server exited; releasing the terminal");
|
|
@@ -255,35 +226,27 @@ async function main() {
|
|
|
255
226
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
256
227
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
257
228
|
}
|
|
258
|
-
// Only when RUN, not when imported
|
|
259
|
-
// this file for `parseArgs` started a whole server
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// and `node dist/index.js` both still take this branch.
|
|
229
|
+
// Only when RUN, not when imported: `void main()` at module scope meant importing
|
|
230
|
+
// this file for `parseArgs` started a whole server. `node
|
|
231
|
+
// --experimental-strip-types src/index.ts` and `node dist/index.js` both take
|
|
232
|
+
// this branch.
|
|
263
233
|
function isEntryPoint() {
|
|
264
234
|
const arg = process.argv[1];
|
|
265
235
|
if (!arg)
|
|
266
236
|
return false;
|
|
267
|
-
// `npm i -g teapot-coding-agent` puts a SYMLINK at node_modules/.bin/teapot
|
|
268
|
-
// npm invokes the target THROUGH it
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// in silence. Reporting `teapot` did nothing and needing `node dist/index.js`
|
|
272
|
-
// was the only symptom. `realpathSync` is what resolves it.
|
|
237
|
+
// `npm i -g teapot-coding-agent` puts a SYMLINK at node_modules/.bin/teapot and
|
|
238
|
+
// npm invokes the target THROUGH it, so `process.argv[1]` is the symlink path,
|
|
239
|
+
// NOT the file it points at: the installed command then exited 0 in silence.
|
|
240
|
+
// `realpathSync` is what resolves it.
|
|
273
241
|
try {
|
|
274
242
|
const self = realpathSync(fileURLToPath(import.meta.url));
|
|
275
243
|
const entry = realpathSync(arg);
|
|
276
244
|
if (self === entry)
|
|
277
245
|
return true;
|
|
278
246
|
// `self === entry` is the ONLY correct test: it is the definition of being the
|
|
279
|
-
// entry point.
|
|
280
|
-
//
|
|
281
|
-
// for EVERY test file that imports this module, so importing it booted a real
|
|
247
|
+
// entry point. A suffix check such as `endsWith("/index.ts")` is true for
|
|
248
|
+
// EVERY test file that imports this module, so importing it booted a real
|
|
282
249
|
// server on port 7788 and `cli-args.test.ts` failed with EADDRINUSE.
|
|
283
|
-
//
|
|
284
|
-
// Keep a basename check as a safety net for a bundler that rewrites paths, but
|
|
285
|
-
// require BOTH that we are the module AND that nothing else is: an argv[1] that
|
|
286
|
-
// resolves to a DIFFERENT file is by definition not this module's entry.
|
|
287
250
|
return false;
|
|
288
251
|
}
|
|
289
252
|
catch {
|
package/dist/master.js
CHANGED
|
@@ -15,6 +15,40 @@ import { parseSchedule, matches, nextFireAt } from "./scheduler/cron.js";
|
|
|
15
15
|
import { bus } from "./bus.js";
|
|
16
16
|
import { fetchModelList, peekModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
|
|
17
17
|
/** how deep sub-agent spawning may nest (parent=0, its subs=1, …) */
|
|
18
|
+
/**
|
|
19
|
+
* Did the effective data dir come from an ENV OVERRIDE that disagrees with the
|
|
20
|
+
* config file's own `dataDir`? (#143)
|
|
21
|
+
*
|
|
22
|
+
* When it does, the config's session pointers are meaningless: they name
|
|
23
|
+
* directories under the OLD root, and this process is looking at a different
|
|
24
|
+
* one. Every agent therefore mints a fresh session — and `recordSession` writes
|
|
25
|
+
* those new pointers back into the config, destroying the real mapping. The
|
|
26
|
+
* caller then throws the new root away (a test's temp dir), leaving a config that
|
|
27
|
+
* points at sessions that never existed.
|
|
28
|
+
*
|
|
29
|
+
* That is not hypothetical: `test/cli-boot-and-host.test.ts` spawns the built CLI
|
|
30
|
+
* with `TEAPOT_DATA_DIR=<temp>` but NO config override, so `pnpm test` on a
|
|
31
|
+
* developer machine rewrote their real config and orphaned 493 sessions' pointers
|
|
32
|
+
* — twice, before this guard. On CI it is invisible, because CI has no config
|
|
33
|
+
* file to damage.
|
|
34
|
+
*
|
|
35
|
+
* So: a mismatched root makes session resolution READ-ONLY. The boot still works
|
|
36
|
+
* (agents get working sessions in the new root) — it just does not rewrite a
|
|
37
|
+
* config that is describing somebody else's data dir.
|
|
38
|
+
*/
|
|
39
|
+
function dataDirOverrideMismatch(configFilePath, effective) {
|
|
40
|
+
if (!process.env.TEAPOT_DATA_DIR)
|
|
41
|
+
return false; // not an override: the file is the truth
|
|
42
|
+
try {
|
|
43
|
+
const raw = JSON.parse(readFileSync(configFilePath, "utf8"));
|
|
44
|
+
if (typeof raw.dataDir !== "string" || !raw.dataDir)
|
|
45
|
+
return false;
|
|
46
|
+
return path.resolve(raw.dataDir) !== path.resolve(effective);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false; // unreadable/new config — nothing to protect
|
|
50
|
+
}
|
|
51
|
+
}
|
|
18
52
|
const MAX_SPAWN_DEPTH = 3;
|
|
19
53
|
/**
|
|
20
54
|
* How long boot waits for an endpoint's model catalogue (#143).
|
|
@@ -322,6 +356,16 @@ export class Master {
|
|
|
322
356
|
constructor(config, configPath) {
|
|
323
357
|
this.config = config;
|
|
324
358
|
this.configPath = configPath;
|
|
359
|
+
// #143: an env data-dir override that disagrees with the config file means
|
|
360
|
+
// this config describes a DIFFERENT data dir — its session pointers cannot be
|
|
361
|
+
// valid here, so rewriting them would destroy the real mapping. See
|
|
362
|
+
// dataDirOverrideMismatch().
|
|
363
|
+
this.sessionsReadOnly = dataDirOverrideMismatch(configPath, config.dataDir);
|
|
364
|
+
if (this.sessionsReadOnly) {
|
|
365
|
+
console.warn(`[teapot] TEAPOT_DATA_DIR (${config.dataDir}) disagrees with the config's own dataDir; ` +
|
|
366
|
+
`sessions will be resolved in the override but NOT written back to ${configPath}. ` +
|
|
367
|
+
`Set TEAPOT_CONFIG to an isolated config to silence this.`);
|
|
368
|
+
}
|
|
325
369
|
}
|
|
326
370
|
/**
|
|
327
371
|
* Persist current logical config back to disk (lossless via raw user config).
|
|
@@ -382,6 +426,11 @@ export class Master {
|
|
|
382
426
|
* idle, for a single request.
|
|
383
427
|
*/
|
|
384
428
|
metaWaited = new Set();
|
|
429
|
+
/**
|
|
430
|
+
* #143: `TEAPOT_DATA_DIR` points somewhere other than the config's own dataDir,
|
|
431
|
+
* so session pointers must not be written back (see dataDirOverrideMismatch).
|
|
432
|
+
*/
|
|
433
|
+
sessionsReadOnly = false;
|
|
385
434
|
/** write once and clear the dirty flag — used at the end of a batched section */
|
|
386
435
|
flushConfig() {
|
|
387
436
|
this.configDirty = false;
|
|
@@ -786,40 +835,19 @@ export class Master {
|
|
|
786
835
|
if (!parent)
|
|
787
836
|
return;
|
|
788
837
|
// the waiter re-checks whether ITS targets are all settled, so waking on
|
|
789
|
-
// every movement is correct and cheap
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
// the
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
// transitions after a spawn are ["idle","running","idle"], so the first is
|
|
798
|
-
// always the spurious one.
|
|
799
|
-
//
|
|
800
|
-
// So the message waits for a child that has actually STARTED. A real
|
|
801
|
-
// completion still reports (it goes running -> idle), and the wake above is
|
|
802
|
-
// unconditional, so `wait_children` is unaffected either way.
|
|
803
|
-
// #130: a spawned child is `idle` until start() runs, and that lazy-load
|
|
804
|
-
// flip is its FIRST transition — so requiring a prior `running` is what stops
|
|
805
|
-
// the parent being told the child settled before it began.
|
|
806
|
-
// #135: reworded, because the old line was wrong in two ways.
|
|
838
|
+
// every movement is correct and cheap. ANCESTORS too: a waiter's targets
|
|
839
|
+
// now span the whole subtree, so a grandchild's movement must reach it.
|
|
840
|
+
this.wakeAncestorWaiters(ac.parent);
|
|
841
|
+
// #130: report only a SETTLED child, never its birth. A freshly spawned
|
|
842
|
+
// child is `idle` before `start()` runs, so that first idle->running flip
|
|
843
|
+
// would tell the parent "@kid is now idle" the instant it existed.
|
|
844
|
+
// Requiring a prior `running` is what filters it out; the wake above is
|
|
845
|
+
// unconditional, so `wait_children` is unaffected.
|
|
807
846
|
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
// prompt below). So the nudge
|
|
813
|
-
// restated as an instruction what the report already stated as fact, and
|
|
814
|
-
// invited the parent to `finish()` ITS OWN goal over a child's status.
|
|
815
|
-
//
|
|
816
|
-
// The new wording says what happened and what to do about it, and points at
|
|
817
|
-
// the report when there is one:
|
|
818
|
-
//
|
|
819
|
-
// child gave a summary -> "…returned without calling finish(); its final
|
|
820
|
-
// message follows." (the report arrives separately)
|
|
821
|
-
// child said nothing -> "…went idle without reporting. If that is
|
|
822
|
-
// unexpected, message it or re-spawn it."
|
|
847
|
+
// #135: the wording addresses the CHILD's outcome, not the parent's goal.
|
|
848
|
+
// "Continue your task, or finish()..." invited the parent to finish its own
|
|
849
|
+
// goal over a child's status; the child's report already states what
|
|
850
|
+
// happened, so this points at it instead.
|
|
823
851
|
if (this.reportedRunning.has(agentId) && SETTLED.has(status))
|
|
824
852
|
parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
|
|
825
853
|
(status === "error"
|
|
@@ -838,11 +866,28 @@ export class Master {
|
|
|
838
866
|
const hooks = {
|
|
839
867
|
depth: myDepth,
|
|
840
868
|
spawn: (o) => this.spawnChildFor(self, o),
|
|
841
|
-
list: () =>
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
869
|
+
list: () =>
|
|
870
|
+
// DIRECT children are the ones this agent can address (stop/message are
|
|
871
|
+
// refused for anything deeper), but a descendant still working is
|
|
872
|
+
// invisible to the parent unless it is LISTED — and the parent then
|
|
873
|
+
// resumes over it. So the whole subtree is listed, each row naming its
|
|
874
|
+
// own `parent` and flagged `nested`, which is what tells the model who
|
|
875
|
+
// to address instead of guessing a level (the error `message_agent`
|
|
876
|
+
// raises names the owner for the same reason).
|
|
877
|
+
this.subtreeOf(ac.id).flatMap(({ id, depth, parent }) => {
|
|
878
|
+
const inst = this.agents.get(id);
|
|
879
|
+
if (!inst)
|
|
880
|
+
return []; // suspended: not addressable, so not listed
|
|
881
|
+
return [
|
|
882
|
+
{
|
|
883
|
+
id,
|
|
884
|
+
status: inst.status,
|
|
885
|
+
goal: inst.goal.text,
|
|
886
|
+
parent,
|
|
887
|
+
nested: depth > 1,
|
|
888
|
+
},
|
|
889
|
+
];
|
|
890
|
+
}),
|
|
846
891
|
stop: (ids) => this.stopChildrenFor(ac.id, ids),
|
|
847
892
|
message: (id, text) => this.messageChild(ac.id, id, text),
|
|
848
893
|
wait: (ids, ms) =>
|
|
@@ -875,6 +920,11 @@ export class Master {
|
|
|
875
920
|
if (!entry)
|
|
876
921
|
return; // not a configured agent — nothing to persist yet
|
|
877
922
|
entry.session = sessionId;
|
|
923
|
+
// #143: never write session pointers into a config that is describing another
|
|
924
|
+
// data dir — every one of them would be wrong, and the correct mapping is
|
|
925
|
+
// destroyed. The in-memory session above still works for this process.
|
|
926
|
+
if (this.sessionsReadOnly)
|
|
927
|
+
return;
|
|
878
928
|
this.saveConfig();
|
|
879
929
|
}
|
|
880
930
|
/** spawn-tree depth of an agent (0 = top level); unknown → 0 */
|
|
@@ -905,6 +955,35 @@ export class Master {
|
|
|
905
955
|
}
|
|
906
956
|
return out;
|
|
907
957
|
}
|
|
958
|
+
/**
|
|
959
|
+
* Every configured descendant of `id`, at any depth, with its depth (1 = direct
|
|
960
|
+
* child) and its own parent id.
|
|
961
|
+
*
|
|
962
|
+
* A parent's work is not finished while anything it caused is still running,
|
|
963
|
+
* and that includes a sub-agent's sub-agent: C can delegate to G and return its
|
|
964
|
+
* own turn, leaving C idle while G works. Deciding from DIRECT children alone
|
|
965
|
+
* therefore let an ancestor report "all settled" over a live grandchild — the
|
|
966
|
+
* reported "it delegates further and the parent never knows".
|
|
967
|
+
*
|
|
968
|
+
* Returns configured ids (not instances) so a suspended descendant still counts
|
|
969
|
+
* as part of the subtree. `seen` guards a cycle in `parent`.
|
|
970
|
+
*/
|
|
971
|
+
subtreeOf(id, depth = 1, seen = new Set()) {
|
|
972
|
+
const out = [];
|
|
973
|
+
for (const a of this.config.agents) {
|
|
974
|
+
if (a.parent !== id)
|
|
975
|
+
continue;
|
|
976
|
+
if (seen.has(a.id))
|
|
977
|
+
continue;
|
|
978
|
+
seen.add(a.id);
|
|
979
|
+
out.push({ id: a.id, depth, parent: id }, ...this.subtreeOf(a.id, depth + 1, seen));
|
|
980
|
+
}
|
|
981
|
+
return out;
|
|
982
|
+
}
|
|
983
|
+
/** the ids of every descendant of `id`, at any depth */
|
|
984
|
+
descendantsOf(id, seen = new Set()) {
|
|
985
|
+
return this.subtreeOf(id, 1, seen).map((x) => x.id);
|
|
986
|
+
}
|
|
908
987
|
/**
|
|
909
988
|
* Create a sub-agent on behalf of `parent`. context "fork" writes a
|
|
910
989
|
* sub_fork header into the child log pointing at the parent's current
|
|
@@ -1044,9 +1123,12 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1044
1123
|
* while parked. Falls back to a timeout so waits can never hang forever.
|
|
1045
1124
|
*/
|
|
1046
1125
|
waitChildren(parentId, ids, timeoutMs, signal) {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1126
|
+
// The whole DESCENDANT subtree, not just direct children: C can delegate to G
|
|
1127
|
+
// and return its own turn, so C reads idle while G works. Waiting on C must
|
|
1128
|
+
// therefore mean "C and everything C caused" — otherwise an ancestor resumes
|
|
1129
|
+
// over live work it cannot see (see descendantsOf).
|
|
1130
|
+
const named = ids && ids.length ? ids : this.descendantsOf(parentId);
|
|
1131
|
+
const targets = [...new Set(named.flatMap((id) => [id, ...this.descendantsOf(id)]))].filter((id) => this.agents.has(id));
|
|
1050
1132
|
if (targets.length === 0)
|
|
1051
1133
|
return Promise.resolve({ note: "no live sub-agents to wait for" });
|
|
1052
1134
|
// isBusy(), not the raw status: parkForTool overwrites a parked child's
|
|
@@ -1104,6 +1186,28 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1104
1186
|
catch { /* ignore */ }
|
|
1105
1187
|
}
|
|
1106
1188
|
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Wake the waiters of EVERY ancestor of an agent, not just its direct parent.
|
|
1191
|
+
*
|
|
1192
|
+
* A parked `wait_children` is woken by lifecycle events, and its targets now
|
|
1193
|
+
* span the whole subtree — so a grandchild settling has to reach the
|
|
1194
|
+
* grandparent, or the wake never arrives and the ancestor sits until its
|
|
1195
|
+
* timeout even though the work finished.
|
|
1196
|
+
*
|
|
1197
|
+
* `parentId` is passed in rather than looked up: the caller already has it, and
|
|
1198
|
+
* a spawned child that is not PERSISTED (a test, or a transient spawn) is absent
|
|
1199
|
+
* from `config.agents`, so a lookup would find no parent at all and the wake
|
|
1200
|
+
* would be silently skipped.
|
|
1201
|
+
*/
|
|
1202
|
+
wakeAncestorWaiters(parentId) {
|
|
1203
|
+
const seen = new Set();
|
|
1204
|
+
let cur = parentId;
|
|
1205
|
+
while (cur && !seen.has(cur)) {
|
|
1206
|
+
seen.add(cur);
|
|
1207
|
+
this.wakeParentWaiters(cur);
|
|
1208
|
+
cur = this.config.agents.find((a) => a.id === cur)?.parent;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1107
1211
|
async messageChild(parentId, childId, text) {
|
|
1108
1212
|
// childId is normally the live agent id — but an internal session id
|
|
1109
1213
|
// (sessions/<id>, the stable timeline handle) also resolves, so callers
|
|
@@ -1165,9 +1269,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1165
1269
|
if (!parentId)
|
|
1166
1270
|
return;
|
|
1167
1271
|
// any child lifecycle movement wakes a parked wait_children (the waiter
|
|
1168
|
-
// re-checks whether its targets are all settled)
|
|
1169
|
-
|
|
1170
|
-
|
|
1272
|
+
// re-checks whether its targets are all settled) — ancestors included, since
|
|
1273
|
+
// a waiter's targets span its whole subtree
|
|
1274
|
+
this.wakeAncestorWaiters(parentId);
|
|
1171
1275
|
const parent = this.agents.get(parentId);
|
|
1172
1276
|
if (!parent)
|
|
1173
1277
|
return;
|
|
@@ -1203,29 +1307,12 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1203
1307
|
if (parent.status !== "running")
|
|
1204
1308
|
parent.start(`sub-agent ${ac.id} error`);
|
|
1205
1309
|
}
|
|
1206
|
-
// #63: a child's activity is NOT mirrored into the parent's log.
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
// 1. The rows were invisible, because `"sub"` was never in the web UI's
|
|
1213
|
-
// FEED_TYPES — so the filter dropped them and the feature did nothing
|
|
1214
|
-
// for as long as it existed (#40).
|
|
1215
|
-
// 2. Once it was made to work (#40), the parent's timeline was flooded:
|
|
1216
|
-
// the operator reads a delegation as "what is this chat doing", not as
|
|
1217
|
-
// a firehose of a child's work.
|
|
1218
|
-
//
|
|
1219
|
-
// The mirroring bought nothing. `sub` rows are display-only:
|
|
1220
|
-
// `rebuildMessagesFrom` never reads them, so the model never saw them and
|
|
1221
|
-
// the child's own history was never duplicated into the parent's context.
|
|
1222
|
-
// What the parent actually needs — the child's report — already arrives as
|
|
1223
|
-
// a harness prompt above, which is the channel the model reads.
|
|
1224
|
-
//
|
|
1225
|
-
// So the right fix is to stop writing them. A chat's timeline is its own.
|
|
1226
|
-
// If per-child detail is wanted in the UI later, it belongs behind an
|
|
1227
|
-
// explicit filter ("show sub-agent activity"), reading each child's own log
|
|
1228
|
-
// rather than interleaving a copy into the parent's.
|
|
1310
|
+
// #63: a child's activity is NOT mirrored into the parent's log. Mirroring
|
|
1311
|
+
// bought nothing — `sub` rows are display-only (`rebuildMessagesFrom` never
|
|
1312
|
+
// read them, so the model never saw them) and once they DID render (#40) the
|
|
1313
|
+
// parent's timeline was flooded with a child's transcript. What the parent
|
|
1314
|
+
// needs is the child's report, which already arrives as a harness prompt
|
|
1315
|
+
// above. A chat's timeline is its own.
|
|
1229
1316
|
}
|
|
1230
1317
|
/** sessions root + helpers */
|
|
1231
1318
|
sessionsRoot() {
|
package/dist/scheduler/cron.js
CHANGED
|
@@ -13,8 +13,8 @@ const BOUNDS = [
|
|
|
13
13
|
function parseField(part, min, max) {
|
|
14
14
|
const out = new Set();
|
|
15
15
|
const clampOrThrow = (v) => {
|
|
16
|
-
// out-of-range
|
|
17
|
-
// that
|
|
16
|
+
// accepting an out-of-range value produces a set that never matches, i.e. a
|
|
17
|
+
// cron that silently never fires ("99 * * * *")
|
|
18
18
|
if (!Number.isInteger(v) || v < min || v > max)
|
|
19
19
|
throw new Error(`bad cron field: ${part} (${min}-${max} expected)`);
|
|
20
20
|
return v;
|
|
@@ -88,10 +88,9 @@ export function matches(schedule, d) {
|
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
90
|
* Next fire time after `from`, scanning minute by minute (cheap: a handful of
|
|
91
|
-
* integer compares per minute).
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* A Feb-29-only cron can still legitimately report null most of the year.
|
|
91
|
+
* integer compares per minute). The horizon is a year so an annual cron (a
|
|
92
|
+
* December date seen in August) still resolves; a Feb-29-only cron can
|
|
93
|
+
* legitimately report null most of the year.
|
|
95
94
|
*/
|
|
96
95
|
export function nextFireAt(schedule, from = new Date()) {
|
|
97
96
|
const startMinute = Math.floor(from.getTime() / 60_000) + 1; // next whole minute
|
package/dist/server/term-size.js
CHANGED
|
@@ -5,9 +5,8 @@ export const TERM_BUSY_MS = 400;
|
|
|
5
5
|
export class TermSizeTracker {
|
|
6
6
|
states = new Map();
|
|
7
7
|
/**
|
|
8
|
-
* Request a resize
|
|
9
|
-
*
|
|
10
|
-
* for tests and diagnostics.
|
|
8
|
+
* Request a resize; the sink write happens on the trailing edge of the
|
|
9
|
+
* debounce.
|
|
11
10
|
*/
|
|
12
11
|
request(child, rows, cols, write) {
|
|
13
12
|
const st = this.states.get(child) ?? { rows: 0, cols: 0, timer: null, busyUntil: 0 };
|