trantor 0.18.23 → 0.18.25
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/.claude-plugin/plugin.json +1 -1
- package/bin/crew-payload.mjs +115 -0
- package/bin/crew-runner.mjs +73 -15
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.25",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Payload composition for crew-runner turn prompts — pure, unit-testable (card #5683).
|
|
2
|
+
//
|
|
3
|
+
// A fresh codex seat burned 306k tokens and crash-looped into a remote-compact 404. The runner-side
|
|
4
|
+
// part of that: every turn re-feeds the FULL lessons block (22,298 of the 24,698 chars in codex's
|
|
5
|
+
// last turn file — 90%), plus an unbounded FYI-broadcast backlog that grows across a failure streak
|
|
6
|
+
// and is replayed on every redelivery, on a RESUMED session where it all stacks. So every section
|
|
7
|
+
// here is capped, and the assembled prompt has ONE hard total cap with a visible truncation notice.
|
|
8
|
+
// Below the caps the output is byte-identical to the old string concatenation.
|
|
9
|
+
|
|
10
|
+
export const PAYLOAD_CAPS = Object.freeze({
|
|
11
|
+
wakeCount: 10, // direct/@mention messages: keep the last ~10
|
|
12
|
+
wakeMsgChars: 2000, // per-message body cap (the hub caps card notes at 2000 too)
|
|
13
|
+
bcastCount: 10, // FYI broadcast context: keep the last ~10
|
|
14
|
+
bcastMsgChars: 1000,
|
|
15
|
+
lessonsCount: 15, // top ~15 lessons, ranked by relevance to this turn's trigger
|
|
16
|
+
lessonsChars: 16000,
|
|
17
|
+
totalChars: 40000, // ONE hard total cap for the whole prompt
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const WORD_RE = /[a-z0-9]{4,}/g;
|
|
21
|
+
const n = v => Number(v).toLocaleString("en-US");
|
|
22
|
+
|
|
23
|
+
// ---- wake messages (the task): keep the last `wakeCount`, cap each body ----
|
|
24
|
+
export function capWake(wake, caps = PAYLOAD_CAPS) {
|
|
25
|
+
const list = Array.isArray(wake) ? wake : [];
|
|
26
|
+
const kept = list.slice(-caps.wakeCount);
|
|
27
|
+
const lines = kept.map(m => {
|
|
28
|
+
let body = String(m?.text ?? "");
|
|
29
|
+
if (body.length > caps.wakeMsgChars)
|
|
30
|
+
body = body.slice(0, caps.wakeMsgChars) + ` …[+${n(body.length - caps.wakeMsgChars)} chars of this message dropped]`;
|
|
31
|
+
return `[${m?.from}${m?.to === "all" ? " -> all (mentions you)" : ""}]: ${body}`;
|
|
32
|
+
});
|
|
33
|
+
return { text: lines.join("\n"), kept: kept.length, total: list.length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- FYI broadcast context (context only): keep the last `bcastCount`, cap each body ----
|
|
37
|
+
export function capBcast(bcast, caps = PAYLOAD_CAPS) {
|
|
38
|
+
const list = Array.isArray(bcast) ? bcast : [];
|
|
39
|
+
const kept = list.slice(-caps.bcastCount);
|
|
40
|
+
const lines = kept.map(m => {
|
|
41
|
+
let body = String(m?.text ?? "");
|
|
42
|
+
if (body.length > caps.bcastMsgChars)
|
|
43
|
+
body = body.slice(0, caps.bcastMsgChars) + ` …[+${n(body.length - caps.bcastMsgChars)} chars dropped]`;
|
|
44
|
+
return `[${m?.from} -> all]: ${body}`;
|
|
45
|
+
});
|
|
46
|
+
return { text: lines.join("\n"), kept: kept.length, total: list.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- lessons: top `lessonsCount` ranked by word overlap with this turn's trigger, then char-capped.
|
|
50
|
+
// No trigger (kickoff/pulse) → no signal to rank on, so original order stands.
|
|
51
|
+
export function pickLessons(lessons, trigger = "", caps = PAYLOAD_CAPS) {
|
|
52
|
+
const list = Array.isArray(lessons) ? lessons.filter(Boolean) : [];
|
|
53
|
+
if (!list.length) return { text: "", kept: 0, total: 0 };
|
|
54
|
+
const tw = new Set(String(trigger).toLowerCase().match(WORD_RE) || []);
|
|
55
|
+
const ranked = list
|
|
56
|
+
.map((l, i) => {
|
|
57
|
+
const words = String(l?.text || "").toLowerCase().match(WORD_RE) || [];
|
|
58
|
+
let score = 0;
|
|
59
|
+
for (const w of words) if (tw.has(w)) score++;
|
|
60
|
+
return { l, i, score };
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => b.score - a.score || a.i - b.i)
|
|
63
|
+
.slice(0, caps.lessonsCount)
|
|
64
|
+
.sort((a, b) => a.i - b.i);
|
|
65
|
+
let budget = caps.lessonsChars;
|
|
66
|
+
const lines = [];
|
|
67
|
+
for (const { l } of ranked) {
|
|
68
|
+
const line = `- [${l?.scope}] ${l?.text}`;
|
|
69
|
+
if (line.length > budget) continue; // doesn't fit — a shorter one may
|
|
70
|
+
budget -= line.length + 1;
|
|
71
|
+
lines.push(line);
|
|
72
|
+
}
|
|
73
|
+
if (!lines.length) return { text: "", kept: 0, total: list.length };
|
|
74
|
+
return {
|
|
75
|
+
text: "\n\nLESSONS from previous crews (hard-won — follow them):\n" + lines.join("\n"),
|
|
76
|
+
kept: lines.length,
|
|
77
|
+
total: list.length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- one composer, one hard total cap ----
|
|
82
|
+
// sections: [{ name, text, trim?, order? }] joined in order. `trim: "drop"` removes the whole
|
|
83
|
+
// section when the total is over `totalChars` (lowest `order` dropped first); `trim: "truncate"`
|
|
84
|
+
// cuts the section to the remaining budget. Sections without `trim` are never touched — they are
|
|
85
|
+
// the runner-authored frame. The payload carries a visible notice naming every trim.
|
|
86
|
+
export function composePrompt(sections, caps = PAYLOAD_CAPS) {
|
|
87
|
+
const secs = sections.map(s => ({ ...s, text: String(s?.text ?? "") }));
|
|
88
|
+
let total = secs.reduce((a, s) => a + s.text.length, 0);
|
|
89
|
+
const dropped = [];
|
|
90
|
+
const trimmable = secs.filter(s => s.trim).sort((a, b) => a.order - b.order);
|
|
91
|
+
for (const s of trimmable) {
|
|
92
|
+
if (total <= caps.totalChars) break;
|
|
93
|
+
if (s.trim === "drop") {
|
|
94
|
+
dropped.push(`${s.name} (${n(s.text.length)} chars dropped)`);
|
|
95
|
+
total -= s.text.length;
|
|
96
|
+
s.text = "";
|
|
97
|
+
} else {
|
|
98
|
+
const rest = total - s.text.length;
|
|
99
|
+
const budget = Math.max(0, caps.totalChars - rest);
|
|
100
|
+
dropped.push(`${s.name} (${n(s.text.length)} → ${n(budget)} chars)`);
|
|
101
|
+
s.text = s.text.slice(0, budget) + " …[truncated — payload hit the hard cap]";
|
|
102
|
+
total = rest + s.text.length;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const prompt = secs.map(s => s.text).join("")
|
|
106
|
+
+ (dropped.length
|
|
107
|
+
? `\n\n[PAYLOAD TRUNCATED: hard cap ${n(caps.totalChars)} chars — ${dropped.join("; ")}.]`
|
|
108
|
+
: "");
|
|
109
|
+
return {
|
|
110
|
+
prompt,
|
|
111
|
+
sections: secs.map(s => ({ name: s.name, chars: s.text.length })),
|
|
112
|
+
truncated: dropped.length > 0,
|
|
113
|
+
dropped,
|
|
114
|
+
};
|
|
115
|
+
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project
|
|
|
16
16
|
import { loadOrCreate } from "../lib/identity.mjs";
|
|
17
17
|
import { signedHeaders } from "../lib/signed-fetch.mjs";
|
|
18
18
|
import { ensureEnrolled } from "../lib/enroll.mjs";
|
|
19
|
+
import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
|
|
19
20
|
|
|
20
21
|
const AGENT = process.argv[2];
|
|
21
22
|
const DIR = process.argv[3] || process.cwd();
|
|
@@ -265,6 +266,10 @@ let consecFails = 0;
|
|
|
265
266
|
// every live seat, so two working agents spent the evening reading the same sentence.
|
|
266
267
|
let announced = "";
|
|
267
268
|
let lastErrText = "";
|
|
269
|
+
// #5481: the turn exited 0 with a NULL/empty transcript — the Inception/Mercury trap. The provider
|
|
270
|
+
// burned its whole max_tokens budget on internal reasoning and returned a null completion; the
|
|
271
|
+
// runner used to read that silence as a clean turn while nothing was produced.
|
|
272
|
+
let lastEmptyOutput = false;
|
|
268
273
|
const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
|
|
269
274
|
|
|
270
275
|
// ---- undelivered wake messages (the runner owns delivery, not the hub) ----
|
|
@@ -311,7 +316,9 @@ function loadPending() {
|
|
|
311
316
|
// classifyFailure's set (no bare /expired/) so a healthy transcript never trips it.
|
|
312
317
|
const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
|
|
313
318
|
|
|
314
|
-
function classifyFailure(exit, errText) {
|
|
319
|
+
function classifyFailure(exit, errText, emptyOutput = false) {
|
|
320
|
+
// #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
|
|
321
|
+
if (emptyOutput) return "empty-output";
|
|
315
322
|
const t = (errText || "").toLowerCase();
|
|
316
323
|
if (exit === 127) return "missing-cli";
|
|
317
324
|
// #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
|
|
@@ -328,14 +335,20 @@ function classifyFailure(exit, errText) {
|
|
|
328
335
|
|
|
329
336
|
async function reportFailure(exit, trigger, undelivered = 0) {
|
|
330
337
|
consecFails++;
|
|
331
|
-
const reason = classifyFailure(exit, lastErrText);
|
|
338
|
+
const reason = classifyFailure(exit, lastErrText, lastEmptyOutput);
|
|
332
339
|
const down = consecFails >= 2;
|
|
333
340
|
const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
|
|
334
341
|
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
|
|
335
342
|
const hint = reason === "exhausted" ? " — needs `trantor swap`"
|
|
336
343
|
: reason === "auth" ? " — check credentials"
|
|
337
344
|
: reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
|
|
338
|
-
: reason === "missing-cli" ? " — CLI not on PATH"
|
|
345
|
+
: reason === "missing-cli" ? " — CLI not on PATH"
|
|
346
|
+
// #5481: name the suspected trap, not just the symptom — the dial lives in the provider's
|
|
347
|
+
// opencode model config (limit.output), not in the runner.
|
|
348
|
+
: reason === "empty-output" ? (AGENT === "inception"
|
|
349
|
+
? " — inception: raise max_tokens — diffusion burns budget on reasoning"
|
|
350
|
+
: " — exit 0 with NULL output: raise the provider's max_tokens (reasoning may be eating the budget)")
|
|
351
|
+
: "";
|
|
339
352
|
// The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
|
|
340
353
|
// crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
|
|
341
354
|
const held = undelivered ? ` · holding ${undelivered} undelivered message${undelivered > 1 ? "s" : ""} (will retry)` : "";
|
|
@@ -423,7 +436,8 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
423
436
|
cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 }); herdrAgent("working");
|
|
424
437
|
// inherit stdio so the window shows the agent working live; also capture for sid-parsing.
|
|
425
438
|
// Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
|
|
426
|
-
try { appendFileSync(ERRF, "", { flag: "w" }); } catch {}
|
|
439
|
+
try { appendFileSync(ERRF, "", { flag: "w" }); } catch {} // truncate
|
|
440
|
+
lastEmptyOutput = false;
|
|
427
441
|
// pipefail: without it the sid-capture `| tee` makes a FAILED turn exit 0 (tee's status),
|
|
428
442
|
// so the failure reporter never fires and a dead seat heartbeats green on the bus.
|
|
429
443
|
// A CLI's own explanation for quitting often goes to STDOUT, not stderr — Claude's usage-limit
|
|
@@ -473,8 +487,20 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
473
487
|
effExit = 1;
|
|
474
488
|
log("\x1b[31mexit 0 but turn output shows an auth failure — treating as FAILED (auth)\x1b[0m");
|
|
475
489
|
}
|
|
476
|
-
|
|
477
|
-
|
|
490
|
+
// #5481: the Inception/Mercury trap — exit 0 with a NULL completion. ERRF is the TOTAL output
|
|
491
|
+
// capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
|
|
492
|
+
// opencode family, `| tee /dev/stderr` + the stderr tee for sid seats — line ~448). So an
|
|
493
|
+
// empty ERRF on a clean exit means the turn produced nothing on EITHER stream — and every
|
|
494
|
+
// real CLI prints something on success (drill C pins that), so silence is the trap, not a
|
|
495
|
+
// quiet victory. (Integration note: this was nearly "fixed" into stdout-only detection that
|
|
496
|
+
// never fired — the tee topology is the load-bearing fact; keep this comment with it.)
|
|
497
|
+
if (realExit === 0 && effExit === 0 && !lastErrText.trim()) {
|
|
498
|
+
effExit = 1;
|
|
499
|
+
lastEmptyOutput = true;
|
|
500
|
+
log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
|
|
501
|
+
}
|
|
502
|
+
telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput });
|
|
503
|
+
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
478
504
|
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
479
505
|
return effExit;
|
|
480
506
|
}
|
|
@@ -483,14 +509,35 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
483
509
|
const KICKOFF = process.env.CREW_KICKOFF ||
|
|
484
510
|
`You just joined (your arrival was already announced on the bus). 1) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 2) End your turn.\n\n${RULES}`;
|
|
485
511
|
|
|
486
|
-
let
|
|
512
|
+
let LESSONS_RAW = [];
|
|
487
513
|
async function loadLessons() {
|
|
488
514
|
try {
|
|
489
515
|
const { lessons } = await api(`/lessons?agent=${encodeURIComponent(AGENT)}`);
|
|
490
|
-
if (lessons?.length)
|
|
516
|
+
if (lessons?.length) LESSONS_RAW = lessons;
|
|
491
517
|
} catch {}
|
|
492
518
|
}
|
|
493
519
|
|
|
520
|
+
// card #5683: every section of a turn prompt is capped (bin/crew-payload.mjs) and the whole
|
|
521
|
+
// payload has ONE hard total cap. Codex burned 306k tokens into a remote-compact 404 crash-loop
|
|
522
|
+
// because a resumed session re-fed the full lessons block (22,298 of the 24,698 chars in its last
|
|
523
|
+
// turn file — 90%) plus an unbounded broadcast backlog on EVERY turn, redelivery after redelivery.
|
|
524
|
+
// Below the caps the composition is byte-identical to the old concatenation.
|
|
525
|
+
function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
|
|
526
|
+
const built = composePrompt([
|
|
527
|
+
{ name: "base", text: base },
|
|
528
|
+
{ name: "wake", text: wakeText, trim: "truncate", order: 4 },
|
|
529
|
+
{ name: "ctx", text: ctxText, trim: "drop", order: 1 },
|
|
530
|
+
{ name: "again", text: againText },
|
|
531
|
+
{ name: "tail", text: tailText },
|
|
532
|
+
{ name: "rules", text: rulesText, trim: "drop", order: 3 },
|
|
533
|
+
{ name: "lessons", text: lessons?.text || "", trim: "drop", order: 2 },
|
|
534
|
+
]);
|
|
535
|
+
const parts = built.sections.filter(s => s.chars).map(s => `${s.name} ${s.chars.toLocaleString("en-US")}c`).join(" · ");
|
|
536
|
+
const lessonsNote = lessons && lessons.total ? ` (lessons ${lessons.kept}/${lessons.total})` : "";
|
|
537
|
+
log(`payload: ${parts}${lessonsNote} → ${built.prompt.length.toLocaleString("en-US")}c${built.truncated ? ` \x1b[33mTRUNCATED — ${built.dropped.join("; ")}\x1b[0m` : ""}`);
|
|
538
|
+
return built.prompt;
|
|
539
|
+
}
|
|
540
|
+
|
|
494
541
|
(async () => {
|
|
495
542
|
await loadLessons();
|
|
496
543
|
// start cursor at the CURRENT tip so we don't replay history
|
|
@@ -520,7 +567,7 @@ async function loadLessons() {
|
|
|
520
567
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
521
568
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
522
569
|
|
|
523
|
-
const ec0 = runTurn(KICKOFF
|
|
570
|
+
const ec0 = runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
|
|
524
571
|
if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
|
|
525
572
|
let lastTurnAt = Date.now();
|
|
526
573
|
if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
|
|
@@ -530,7 +577,7 @@ async function loadLessons() {
|
|
|
530
577
|
// pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
|
|
531
578
|
// last turn, so a long turn doesn't stack an immediate pulse on top of itself.
|
|
532
579
|
if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
|
|
533
|
-
const ecp = runTurn(PULSE_PROMPT + "\n\n"
|
|
580
|
+
const ecp = runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
|
|
534
581
|
if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
|
|
535
582
|
lastTurnAt = Date.now();
|
|
536
583
|
log("parked — waiting for the next message or pulse");
|
|
@@ -589,22 +636,33 @@ async function loadLessons() {
|
|
|
589
636
|
// queued, on disk, with a backoff — which is the whole point of the change.
|
|
590
637
|
async function deliverWake() {
|
|
591
638
|
const wake = pendingWake;
|
|
592
|
-
const
|
|
593
|
-
const
|
|
639
|
+
const wakeCapped = capWake(wake);
|
|
640
|
+
const bcastCapped = capBcast(pendingBcast);
|
|
641
|
+
const wakeText = wakeCapped.text
|
|
642
|
+
? `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
|
|
643
|
+
: "";
|
|
644
|
+
const ctxText = bcastCapped.text
|
|
645
|
+
? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
|
|
646
|
+
: "";
|
|
594
647
|
// Say plainly that this is a second look. Without it the model re-reads an old escalation as
|
|
595
648
|
// brand new and can redo work it already half-did before the turn died.
|
|
596
|
-
const
|
|
649
|
+
const againText = deliveryFails
|
|
597
650
|
? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wake.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
|
|
598
651
|
: "";
|
|
599
|
-
const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}${again}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
|
|
600
652
|
await loadLessons();
|
|
653
|
+
const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
|
|
601
654
|
const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
|
|
602
655
|
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
603
656
|
const assigners = [];
|
|
604
657
|
for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
605
658
|
const asked = String(wake[0]?.text || "").replace(/\s+/g, " ").trim().slice(0, 90);
|
|
606
659
|
const tStart = Date.now();
|
|
607
|
-
const
|
|
660
|
+
const prompt = composedTurn({
|
|
661
|
+
wakeText, ctxText, againText,
|
|
662
|
+
tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
|
|
663
|
+
rulesText: RULES, lessons,
|
|
664
|
+
});
|
|
665
|
+
const ec = runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
|
|
608
666
|
const secs = Math.round((Date.now() - tStart) / 1000);
|
|
609
667
|
if (ec) {
|
|
610
668
|
deliveryFails++;
|