teapot-coding-agent 0.25.1 → 0.26.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/dist/agent/agent.js +146 -9
- package/dist/agent/tools.js +55 -5
- package/dist/master.js +31 -0
- package/dist/server/api.js +45 -1
- package/package.json +1 -1
- package/public/assets/{index-DLBs15ef.js → index-BrEdnC4L.js} +8 -8
- package/public/assets/index-BrEdnC4L.js.map +1 -0
- package/public/assets/{shiki-DavJK6MV.js → shiki-D1LowWvE.js} +2 -2
- package/public/assets/{shiki-DavJK6MV.js.map → shiki-D1LowWvE.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DLBs15ef.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -345,6 +345,46 @@ export class Agent {
|
|
|
345
345
|
get compactBudgetIsManual() {
|
|
346
346
|
return this.manualCompactBudget;
|
|
347
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* A context size we are willing to act on.
|
|
350
|
+
*
|
|
351
|
+
* #73: `lastUsage.input` is the provider's own count, so it is normally the
|
|
352
|
+
* most accurate number available — but a provider that under-reports (a proxy
|
|
353
|
+
* that omits the field's real value, a broken gateway, a model served through
|
|
354
|
+
* a shim) makes BOTH safety gates believe the context is nearly empty:
|
|
355
|
+
*
|
|
356
|
+
* maybePrune() est < budget * 0.6 -> 0 prunes, forever
|
|
357
|
+
* maybeCompact() before < budget -> 0 compactions, forever
|
|
358
|
+
*
|
|
359
|
+
* Reproduced: ~40k characters of real conversation, provider reporting
|
|
360
|
+
* `prompt_tokens: 10` — zero prunes, zero compactions, and a gauge reading 10,
|
|
361
|
+
* so nothing looks wrong. The context then overflows for real, with the
|
|
362
|
+
* defence that exists to stop exactly that switched off.
|
|
363
|
+
*
|
|
364
|
+
* So the reported number is used, but never as the ONLY number: it is floored
|
|
365
|
+
* against the local estimate. The estimate is a heuristic; the point is that
|
|
366
|
+
* `??` was the wrong way to combine a measurement with a floor — it only falls
|
|
367
|
+
* back when the value is ABSENT, so a confidently wrong value was trusted more
|
|
368
|
+
* than the guess.
|
|
369
|
+
*
|
|
370
|
+
* The floor is a HALF of the local estimate. A quarter was tried first and
|
|
371
|
+
* was too weak to clear the 0.6-of-budget threshold for a realistic session
|
|
372
|
+
* (estimate 5108 → floored 1277 against a 3600 bar), so the guard still would
|
|
373
|
+
* not have fired. Half keeps the intent — catch "reported 10 when the real
|
|
374
|
+
* prompt is large" — without arguing with a provider whose tokenizer
|
|
375
|
+
* legitimately differs from ours, since a provider within 2x of the estimate
|
|
376
|
+
* is not the failure mode being defended against.
|
|
377
|
+
*/
|
|
378
|
+
contextSizeForBudgeting() {
|
|
379
|
+
const estimated = this.estimateTokens();
|
|
380
|
+
const reported = this.lastUsage?.input;
|
|
381
|
+
if (reported === undefined)
|
|
382
|
+
return estimated;
|
|
383
|
+
// a provider reporting nothing at all is a different bug; treat it as unknown
|
|
384
|
+
if (!Number.isFinite(reported) || reported <= 0)
|
|
385
|
+
return estimated;
|
|
386
|
+
return Math.max(reported, Math.floor(estimated / 2));
|
|
387
|
+
}
|
|
348
388
|
/**
|
|
349
389
|
* Is this agent doing work that `stop()` would actually stop?
|
|
350
390
|
*
|
|
@@ -722,10 +762,19 @@ export class Agent {
|
|
|
722
762
|
droppedEvents: dropped.length,
|
|
723
763
|
tailMode: tail,
|
|
724
764
|
});
|
|
725
|
-
// Seed the new branch's parent link
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
|
|
765
|
+
// Seed the new branch's parent link so its first event chains across
|
|
766
|
+
// instead of starting a disconnected parent:null history (#38).
|
|
767
|
+
//
|
|
768
|
+
// #69: seed from the CUT POINT, not from this branch's tip. The fork event
|
|
769
|
+
// records where the edit was made (`fromEvent` = the last KEPT event), but
|
|
770
|
+
// `lastEventId(currentBranch)` is the newest event on the branch — which is
|
|
771
|
+
// one of the events this edit just DISCARDED. Seeding from the tip chained
|
|
772
|
+
// the new branch to the discarded tail, so lineageOf() walked straight back
|
|
773
|
+
// through it and a restart resurrected every dropped turn.
|
|
774
|
+
//
|
|
775
|
+
// The cut point is the right parent: it is the last event that survives.
|
|
776
|
+
const cutPoint = kept.at(-1)?.id ?? null;
|
|
777
|
+
this.log.seedBranch(newBranch, cutPoint);
|
|
729
778
|
this.currentBranch = newBranch;
|
|
730
779
|
this.messages = msgs;
|
|
731
780
|
await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
|
|
@@ -940,6 +989,17 @@ export class Agent {
|
|
|
940
989
|
...(this.compactPhase ? { compacting: this.compactPhase } : {}),
|
|
941
990
|
},
|
|
942
991
|
pendingPrompts: this.pendingPrompts.length,
|
|
992
|
+
// #78: the IDS as well as the count. The web UI reconciles its local echo
|
|
993
|
+
// list against the live queue, and it was doing that by POSITION
|
|
994
|
+
// (slice(0, queued)) because only a count was published — so whenever the
|
|
995
|
+
// count dipped below the true queue length (a mid-queue cancellation, or a
|
|
996
|
+
// momentary gap), the NEWEST still-queued echoes were dropped and their
|
|
997
|
+
// undelivered log rows reappeared as settled "sent" rows. Identity needs
|
|
998
|
+
// the ids to travel; the count alone cannot express "these two".
|
|
999
|
+
//
|
|
1000
|
+
// Bounded like the rest of the snapshot, and ids are opaque, so this
|
|
1001
|
+
// cannot carry prompt text.
|
|
1002
|
+
pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
|
|
943
1003
|
todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
|
|
944
1004
|
parent: this.opts.parent,
|
|
945
1005
|
awaiting: this.awaitingUser,
|
|
@@ -1114,7 +1174,20 @@ export class Agent {
|
|
|
1114
1174
|
}
|
|
1115
1175
|
/** Start (or resume) autonomous operation toward the goal. */
|
|
1116
1176
|
start(reason = "start") {
|
|
1117
|
-
|
|
1177
|
+
// #76: a stop() immediately followed by start() USED TO BE SILENTLY LOST.
|
|
1178
|
+
//
|
|
1179
|
+
// `stop()` only flips the status via an ENQUEUED task, so between the two
|
|
1180
|
+
// calls the status is still "running" and the guard below returned early —
|
|
1181
|
+
// before `stopRequested = false` could run. The stop then landed, the loop
|
|
1182
|
+
// exited, and the agent ended "stopped" with the restart discarded. The same
|
|
1183
|
+
// window made a stop+restart abort the new round's first tool.
|
|
1184
|
+
//
|
|
1185
|
+
// So a pending stop is not a reason to refuse a start. The loop's own
|
|
1186
|
+
// guards (`stopRequested` re-checked after every await) keep the old turn
|
|
1187
|
+
// from running on, and enqueue() serialises the two tasks, so the restart
|
|
1188
|
+
// is ordered after the stop rather than racing it.
|
|
1189
|
+
const pendingStop = this.stopRequested;
|
|
1190
|
+
if (this.status === "running" && !pendingStop)
|
|
1118
1191
|
return;
|
|
1119
1192
|
this.stopRequested = false;
|
|
1120
1193
|
this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
|
|
@@ -1318,6 +1391,13 @@ export class Agent {
|
|
|
1318
1391
|
const name = err.name;
|
|
1319
1392
|
if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
|
|
1320
1393
|
throw err;
|
|
1394
|
+
// #76: a context-overflow error is the caller's to handle, not ours to
|
|
1395
|
+
// retry. Retrying the SAME oversized request four times with 5s/5s/30s
|
|
1396
|
+
// waits cannot succeed, and it delays the compact-and-retry that would
|
|
1397
|
+
// — so the overflow path never even ran. This is the same
|
|
1398
|
+
// "present, correct, and inert" shape as #54.
|
|
1399
|
+
if (isContextOverflow(err))
|
|
1400
|
+
throw err;
|
|
1321
1401
|
if (attempt >= maxAttempts)
|
|
1322
1402
|
throw err;
|
|
1323
1403
|
const waitMs = waits[Math.min(attempt - 1, waits.length - 1)];
|
|
@@ -1743,6 +1823,20 @@ export class Agent {
|
|
|
1743
1823
|
`(last: ${call.function.name})`);
|
|
1744
1824
|
}
|
|
1745
1825
|
}
|
|
1826
|
+
// #76: every tool_call an assistant message issued must have a matching
|
|
1827
|
+
// tool result, or the provider REJECTS the next request — which is the
|
|
1828
|
+
// "`200 with no choices`" shape #50 traced to a malformed history.
|
|
1829
|
+
//
|
|
1830
|
+
// Two exits could leave some unanswered: `stopRequested` returns early
|
|
1831
|
+
// mid-batch, and a handler that throws (ask_user parking the loop is the
|
|
1832
|
+
// one that bites in practice) propagates out of the whole loop. Restore
|
|
1833
|
+
// has a hole-filler for exactly this; the LIVE array did not, so a session
|
|
1834
|
+
// that hit it produced an invalid next request and then failed with
|
|
1835
|
+
// something that looked like a provider fault.
|
|
1836
|
+
//
|
|
1837
|
+
// So the invariant is enforced here rather than at each exit: whatever
|
|
1838
|
+
// happened, answer what is still outstanding before moving on.
|
|
1839
|
+
await this.answerUnansweredToolCalls(m);
|
|
1746
1840
|
if (finished)
|
|
1747
1841
|
return true;
|
|
1748
1842
|
}
|
|
@@ -1750,6 +1844,37 @@ export class Agent {
|
|
|
1750
1844
|
// nudges the model; this round simply ends (no hard error)
|
|
1751
1845
|
return finished;
|
|
1752
1846
|
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Append a tool result for every tool_call in `m` that has not been answered.
|
|
1849
|
+
*
|
|
1850
|
+
* Scans the tail of `messages` for `tool_call_id`s, so it is correct whichever
|
|
1851
|
+
* path left the batch unfinished — a stop, a throw, or a normal completion
|
|
1852
|
+
* where a handler returned early. Answered calls are left alone.
|
|
1853
|
+
*/
|
|
1854
|
+
async answerUnansweredToolCalls(m) {
|
|
1855
|
+
const calls = m.tool_calls ?? [];
|
|
1856
|
+
if (!calls.length)
|
|
1857
|
+
return;
|
|
1858
|
+
const answered = new Set();
|
|
1859
|
+
for (const existing of this.messages) {
|
|
1860
|
+
if (existing.role === "tool" && existing.tool_call_id)
|
|
1861
|
+
answered.add(existing.tool_call_id);
|
|
1862
|
+
}
|
|
1863
|
+
for (const call of calls) {
|
|
1864
|
+
if (answered.has(call.id))
|
|
1865
|
+
continue;
|
|
1866
|
+
const content = "(not completed — the tool did not run)";
|
|
1867
|
+
this.messages.push({ role: "tool", tool_call_id: call.id, content });
|
|
1868
|
+
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
1869
|
+
callId: call.id,
|
|
1870
|
+
name: call.function.name,
|
|
1871
|
+
ok: false,
|
|
1872
|
+
durationMs: 0,
|
|
1873
|
+
result: content,
|
|
1874
|
+
synthesized: true,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1753
1878
|
buildMessages() {
|
|
1754
1879
|
// deliberately static: [system] + append-only history keeps prefix caches hot.
|
|
1755
1880
|
// The workspace context block (AGENTS.md + ls snapshot) is appended to the
|
|
@@ -2139,7 +2264,7 @@ export class Agent {
|
|
|
2139
2264
|
* protected so current work never loses its footing.
|
|
2140
2265
|
*/
|
|
2141
2266
|
maybePrune() {
|
|
2142
|
-
const est = this.
|
|
2267
|
+
const est = this.contextSizeForBudgeting(); // floored — see #73
|
|
2143
2268
|
const budget = this.opts.contextTokenBudget;
|
|
2144
2269
|
if (!budget || est < budget * 0.6)
|
|
2145
2270
|
return 0; // prune only when it matters
|
|
@@ -2199,7 +2324,7 @@ export class Agent {
|
|
|
2199
2324
|
// for providers that omit usage
|
|
2200
2325
|
// stage 1: clip oversized old tool outputs before considering a summarize
|
|
2201
2326
|
this.maybePrune();
|
|
2202
|
-
const before = this.
|
|
2327
|
+
const before = this.contextSizeForBudgeting(); // floored — see #73
|
|
2203
2328
|
if (!force && before < this.opts.contextTokenBudget)
|
|
2204
2329
|
return;
|
|
2205
2330
|
if (!force && this.opts.autoCompact === false)
|
|
@@ -2492,12 +2617,24 @@ export class Agent {
|
|
|
2492
2617
|
async fork(fromEventId) {
|
|
2493
2618
|
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
2494
2619
|
const parentBranch = this.currentBranch;
|
|
2495
|
-
|
|
2620
|
+
// Where the fork leaves the source branch. Recorded on the event AND used
|
|
2621
|
+
// to seed the new branch, so the two can never disagree.
|
|
2622
|
+
const fromEvent = fromEventId ?? this.log.lastEventId(parentBranch);
|
|
2623
|
+
// #69: the fork event must be appended under the SOURCE branch. Appending it
|
|
2624
|
+
// under the new one gave it parent:null (nothing had been written to that
|
|
2625
|
+
// branch yet), so lineageOf() walked an empty chain and a restart rebuilt
|
|
2626
|
+
// the fork with NO history at all — the same class of bug editPromptAt had,
|
|
2627
|
+
// fixed on one path only. editPromptAt already appends under the old
|
|
2628
|
+
// branch for exactly this reason.
|
|
2629
|
+
await this.log.append("fork", this.currentSession, parentBranch, {
|
|
2496
2630
|
fromSession: this.currentSession,
|
|
2497
2631
|
fromBranch: parentBranch,
|
|
2498
|
-
fromEvent
|
|
2632
|
+
fromEvent,
|
|
2499
2633
|
newBranch,
|
|
2500
2634
|
});
|
|
2635
|
+
// Seed the new branch's parent to the point it leaves the source, so its
|
|
2636
|
+
// first event chains across instead of starting at parent:null.
|
|
2637
|
+
this.log.seedBranch(newBranch, fromEvent);
|
|
2501
2638
|
this.currentBranch = newBranch;
|
|
2502
2639
|
this.messages = [...this.messages]; // independent history copy
|
|
2503
2640
|
return { session: this.currentSession, branch: newBranch };
|
package/dist/agent/tools.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Tool specs are plain JSON-schema function definitions — provider-agnostic.
|
|
4
4
|
*/
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync, promises as fs, realpathSync } from "node:fs";
|
|
6
|
+
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
|
const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
@@ -60,14 +60,64 @@ export function safeJoin(cwd, p) {
|
|
|
60
60
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
61
61
|
throw new Error(`path escapes workspace: ${p}`);
|
|
62
62
|
}
|
|
63
|
-
// symlinks inside the workspace can point anywhere — resolve the real
|
|
64
|
-
//
|
|
63
|
+
// symlinks inside the workspace can point anywhere — resolve the real target
|
|
64
|
+
// and confine THAT too (path.resolve alone doesn't follow links)
|
|
65
|
+
//
|
|
66
|
+
// #74: the old code resolved `abs` and swallowed a throw, treating failure as
|
|
67
|
+
// "fine". That is exactly backwards, and the failure is the NORMAL case:
|
|
68
|
+
// `realpathSync` throws ENOENT for a file that does not exist yet, which is
|
|
69
|
+
// every write_file creating something new. So `real` stayed as the LEXICAL
|
|
70
|
+
// path — which is inside the workspace — and the symlink guard never ran.
|
|
71
|
+
//
|
|
72
|
+
// Demonstrated: with `esc -> /outside` (a real directory), safeJoin allowed
|
|
73
|
+
// `esc/pwned.txt` and write_file wrote straight through it, landing outside
|
|
74
|
+
// the workspace. The dangling case happened to fail later with ENOTDIR, which
|
|
75
|
+
// is luck, not a control.
|
|
76
|
+
//
|
|
77
|
+
// So on ENOENT/ENOTDIR, resolve the nearest EXISTING ancestor instead of
|
|
78
|
+
// giving up. That still catches the escape — the ancestor IS the symlink —
|
|
79
|
+
// while allowing genuinely new files inside the workspace.
|
|
65
80
|
let real = abs;
|
|
66
81
|
try {
|
|
67
82
|
real = realpathSync(abs);
|
|
68
83
|
}
|
|
69
|
-
catch {
|
|
70
|
-
|
|
84
|
+
catch (err) {
|
|
85
|
+
const code = err.code;
|
|
86
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
87
|
+
// Walk up one component at a time. A component that is itself a SYMLINK
|
|
88
|
+
// must be resolved even when its target does not exist — realpathSync
|
|
89
|
+
// throws ENOENT for the dangling link itself, so naively walking to the
|
|
90
|
+
// first existing ancestor steps OVER the symlink and loses it. So read
|
|
91
|
+
// the link and check its target directly.
|
|
92
|
+
let probe = path.dirname(abs);
|
|
93
|
+
for (;;) {
|
|
94
|
+
try {
|
|
95
|
+
real = realpathSync(probe);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
catch (inner) {
|
|
99
|
+
if (inner.code !== "ENOENT")
|
|
100
|
+
break;
|
|
101
|
+
// this component is a dangling symlink: resolve where it POINTS
|
|
102
|
+
try {
|
|
103
|
+
const target = readlinkSync(probe);
|
|
104
|
+
real = path.isAbsolute(target) ? target : path.resolve(path.dirname(probe), target);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* not a symlink after all — keep walking */
|
|
109
|
+
}
|
|
110
|
+
const next = path.dirname(probe);
|
|
111
|
+
if (next === probe)
|
|
112
|
+
break; // reached the filesystem root
|
|
113
|
+
probe = next;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// keep the un-resolved tail so the comparison below sees the full path
|
|
117
|
+
const tail = path.relative(probe, abs);
|
|
118
|
+
if (tail && !tail.startsWith(".."))
|
|
119
|
+
real = path.join(real, tail);
|
|
120
|
+
}
|
|
71
121
|
}
|
|
72
122
|
const relReal = path.relative(cwd, real);
|
|
73
123
|
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
package/dist/master.js
CHANGED
|
@@ -229,6 +229,22 @@ function printAgentEvent(e) {
|
|
|
229
229
|
if (line)
|
|
230
230
|
console.log(`${dim(ts)} ${who} ${line}`);
|
|
231
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Reduce an agent id to a safe single path segment.
|
|
234
|
+
*
|
|
235
|
+
* Agent ids become directory names, so anything path-like in one is an escape
|
|
236
|
+
* from the sessions root (#72). Kept in step with the per-call-site
|
|
237
|
+
* `.replace(/[^\w.-]/g, "-")` calls, and applied in `addAgent` too so the
|
|
238
|
+
* guarantee holds for every caller.
|
|
239
|
+
*/
|
|
240
|
+
export function sanitizeAgentId(id) {
|
|
241
|
+
const cleaned = String(id ?? "")
|
|
242
|
+
.replace(/[^\w.-]/g, "-")
|
|
243
|
+
// a leading dot would create a hidden dir; ".." would climb out
|
|
244
|
+
.replace(/^\.+/, "")
|
|
245
|
+
.slice(0, 60);
|
|
246
|
+
return cleaned || "agent";
|
|
247
|
+
}
|
|
232
248
|
export class Master {
|
|
233
249
|
agents = new Map();
|
|
234
250
|
/**
|
|
@@ -402,6 +418,21 @@ export class Master {
|
|
|
402
418
|
* an older one's history.
|
|
403
419
|
*/
|
|
404
420
|
async addAgent(ac, opts = {}) {
|
|
421
|
+
// #72: an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
|
|
422
|
+
// must be a safe path segment. Every caller sanitised its own id — the HTTP
|
|
423
|
+
// route, spawnChildFor's child name — except the ACP adapter, which passed
|
|
424
|
+
// the raw client string through, so `session/load {"sessionId":"../../x"}`
|
|
425
|
+
// created an agent logged outside <dataDir>/sessions/.
|
|
426
|
+
//
|
|
427
|
+
// Sanitised HERE as well as at the call sites: this is the one place that
|
|
428
|
+
// guarantees the property, so a future caller cannot reintroduce the escape
|
|
429
|
+
// by forgetting. A caller that already sanitised is unaffected — the
|
|
430
|
+
// transform is idempotent on a safe id.
|
|
431
|
+
const safeId = sanitizeAgentId(ac.id);
|
|
432
|
+
if (safeId !== ac.id) {
|
|
433
|
+
console.warn(`[teapot] agent id ${JSON.stringify(ac.id)} contained unsafe characters; using ${JSON.stringify(safeId)}`);
|
|
434
|
+
}
|
|
435
|
+
ac = { ...ac, id: safeId };
|
|
405
436
|
if (this.agents.has(ac.id))
|
|
406
437
|
throw new Error(`agent id already exists: ${ac.id}`);
|
|
407
438
|
// provider resolution: inline overrides > named provider > legacy llm block
|
package/dist/server/api.js
CHANGED
|
@@ -106,6 +106,16 @@ export function buildApp(master) {
|
|
|
106
106
|
});
|
|
107
107
|
// per-agent terminal spawn guard
|
|
108
108
|
const termCounts = new Map();
|
|
109
|
+
/**
|
|
110
|
+
* Agent ids reserved by an in-flight create (#71).
|
|
111
|
+
*
|
|
112
|
+
* The uniqueness loop is a check, not a reservation, and `addAgent` only
|
|
113
|
+
* publishes the id after awaiting — so concurrent creates all computed the
|
|
114
|
+
* same "unique" id and all persisted an entry. Reserving before the first
|
|
115
|
+
* await closes the window. Entries are released as soon as the agent is
|
|
116
|
+
* published or the create fails, so this never grows.
|
|
117
|
+
*/
|
|
118
|
+
const idReservations = new Set();
|
|
109
119
|
// keyed by CHILD, not agent: one agent may have up to 10 concurrent
|
|
110
120
|
// terminals, each with its own size and its own foreground program
|
|
111
121
|
const termSizes = new TermSizeTracker();
|
|
@@ -169,6 +179,12 @@ export function buildApp(master) {
|
|
|
169
179
|
app.get("/api/agents/:id/term", upgradeWebSocket((c) => {
|
|
170
180
|
const agentId = c.req.param("id") ?? "";
|
|
171
181
|
let child = null;
|
|
182
|
+
// #70: whether THIS connection was admitted to the per-agent terminal
|
|
183
|
+
// budget. Declared per connection (not inside onOpen) because onClose is a
|
|
184
|
+
// sibling handler: a connection refused at the cap never incremented the
|
|
185
|
+
// counter, so onClose must not decrement for it — that asymmetry walked
|
|
186
|
+
// the counter below the live-shell count and disabled the cap entirely.
|
|
187
|
+
let admitted = false;
|
|
172
188
|
const cleanup = () => {
|
|
173
189
|
if (!child)
|
|
174
190
|
return;
|
|
@@ -198,7 +214,12 @@ export function buildApp(master) {
|
|
|
198
214
|
return;
|
|
199
215
|
}
|
|
200
216
|
termCounts.set(agentId, cur + 1);
|
|
217
|
+
admitted = true;
|
|
201
218
|
if (!agent) {
|
|
219
|
+
// the slot was just taken and this connection owns it, so release
|
|
220
|
+
// it here rather than leaving onClose to work it out
|
|
221
|
+
termCounts.set(agentId, cur);
|
|
222
|
+
admitted = false;
|
|
202
223
|
send({ kind: "exit", error: `no such agent: ${agentId}` });
|
|
203
224
|
return;
|
|
204
225
|
}
|
|
@@ -250,6 +271,11 @@ export function buildApp(master) {
|
|
|
250
271
|
if (dead)
|
|
251
272
|
termSizes.forget(dead);
|
|
252
273
|
cleanup();
|
|
274
|
+
// #70: only an ADMITTED connection owns a slot. A connection refused
|
|
275
|
+
// at the cap never incremented, so decrementing for it is exactly
|
|
276
|
+
// what drove the counter negative and disabled the limit.
|
|
277
|
+
if (!admitted)
|
|
278
|
+
return;
|
|
253
279
|
const n = (termCounts.get(agentId) ?? 1) - 1;
|
|
254
280
|
if (n <= 0)
|
|
255
281
|
termCounts.delete(agentId);
|
|
@@ -281,10 +307,27 @@ export function buildApp(master) {
|
|
|
281
307
|
let n = 2;
|
|
282
308
|
// ids must be unique among RUNNING agents AND persisted config entries —
|
|
283
309
|
// a config-only agent (not yet loaded) would otherwise collide on addAgent
|
|
284
|
-
|
|
310
|
+
//
|
|
311
|
+
// #71: the loop below is a CHECK, not a RESERVATION, and addAgent only
|
|
312
|
+
// publishes the id after several awaits (mkdirSync, agent.init()). Every
|
|
313
|
+
// concurrent request therefore passed the check, computed the same "unique"
|
|
314
|
+
// id, and all persisted an entry — measured: 3 concurrent creates → 3 config
|
|
315
|
+
// entries, and with 5 concurrent spawns each minting its own session dir,
|
|
316
|
+
// `resolveSessionDir` binds to the first entry on restart and the rest are
|
|
317
|
+
// orphaned.
|
|
318
|
+
//
|
|
319
|
+
// So the id is RESERVED synchronously, before the first await. The set is
|
|
320
|
+
// per-process and only guards the window; a real agent arriving through
|
|
321
|
+
// another path still collides correctly via addAgent's own check.
|
|
322
|
+
while ((master.agents.has(id) || master.config.agents.some((a) => a.id === id) || idReservations.has(id)))
|
|
285
323
|
id = `${base.slice(0, 38)}-${n++}`;
|
|
324
|
+
idReservations.add(id);
|
|
325
|
+
const releaseId = () => idReservations.delete(id);
|
|
286
326
|
try {
|
|
327
|
+
// the reservation covers the awaits inside addAgent; once the agent is
|
|
328
|
+
// published the reservation is redundant and must be released
|
|
287
329
|
const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, { persist: true, fresh: true });
|
|
330
|
+
releaseId();
|
|
288
331
|
// NOTE: creation no longer auto-starts an LLM loop — the agent sits in
|
|
289
332
|
// "stopped" (a lazy, zero-cost session) until the operator sends the
|
|
290
333
|
// first prompt or presses ▶ start. The web UI relies on this.
|
|
@@ -293,6 +336,7 @@ export function buildApp(master) {
|
|
|
293
336
|
return c.json({ ok: true, agent: agent.snapshot() });
|
|
294
337
|
}
|
|
295
338
|
catch (err) {
|
|
339
|
+
releaseId(); // never leak a reservation on failure
|
|
296
340
|
return c.json({ error: err.message }, 400);
|
|
297
341
|
}
|
|
298
342
|
});
|