@inetafrica/open-claudia 3.4.22 → 3.4.24
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/CHANGELOG.md +17 -0
- package/bin/schedule.js +45 -11
- package/core/handlers.js +26 -6
- package/core/loopback.js +13 -2
- package/core/runner.js +38 -8
- package/core/scheduler.js +215 -34
- package/core/state.js +12 -0
- package/package.json +2 -2
- package/test-provider-compaction.js +93 -0
- package/test-provider-language.js +13 -1
- package/test-provider-pack-review.js +25 -2
- package/test-provider-parity-e2e.js +5 -3
- package/test-provider-scheduler.js +531 -4
- package/test-run-lock.js +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.24
|
|
4
|
+
|
|
5
|
+
- A repeating watch keeps polling while its triggered delivery waits for a busy room, waits to retry after a failed or `/stop`-killed run, or runs. A new hit folds into that one pending delivery instead of queueing another, and the delivered prompt says how many more times the check passed and whether its latest run had cleared. On 24 Sep the event-bus watch went unpolled for 7.5 h behind a busy room, and after a killed delivery attempt its polling never resumed. One-shot watches are unchanged, and the 15-minute "⏳ Watch … waiting" line still goes out once per occurrence.
|
|
6
|
+
- A watch whose check times out 3 times in a row now wakes the agent once, marked "Watch check timing out", and keeps polling; any completed check resets the count. A hanging health check used to be logged `idle (check timed out)` and never alarm. The alarm does not use up a one-shot watch.
|
|
7
|
+
- Wakeups more than ~24.8 days out no longer fire at once. `setTimeout` overflows past 2^31-1 ms, which fired a 2028 reminder 61 s after it was set (17 Sep); far wakeups now sleep in chunks and re-plan until due, then fire once. `schedule-wakeup` refuses a `<when>` more than 10 years out, in the CLI and in the loopback route.
|
|
8
|
+
- A queued delivery is no longer stranded when a jobs-store write throws (a full disk, a torn journal). Those paths returned without a retry timer, so the alarm was never tried again; they now keep the retry armed and say so in watch.log. This matches the watch alarms that triggered during long turns on 21 and 23 Sep and were never delivered. A regression test also pins that a delivery queued behind a long turn survives the turn's compaction and runs once at the next idle moment, on the compacted session; that path already worked.
|
|
9
|
+
- `cron-list` and `watch-list` say where a fired job stands (delivery waiting, running, retrying or dead) instead of listing it as still to come. A triggered watch whose delivery was queued read "EXPIRED (fires on next poll)" (21 Sep), and a fired 2028 reminder still read "fires 2028-08-01" (17 Sep).
|
|
10
|
+
- `open-claudia say` and the send-file/voice/photo routes now say which step failed when the channel adapter refuses the upload, instead of a bare `loopback 500: {"ok":false}` (10 Sep). The adapter's own error stays in bot.log.
|
|
11
|
+
- Release gate: `test-provider-pack-review.js` gives each probe child 120 s instead of 30 s and reports a timeout as a timeout rather than "failed (null)" (v3.4.19, job 43703). The parity suite's cap for it rises to 8 minutes so it cannot undercut its children.
|
|
12
|
+
|
|
13
|
+
## 3.4.23
|
|
14
|
+
|
|
15
|
+
- Watch, wakeup and cron turns now stream their progress (interim narration, and thinking under `/verbose`) like chat turns. The live preview was gated to chat turns only, so a watch-woken turn showed the bell notice and "thinking…" and then nothing until its final answer, however long it worked.
|
|
16
|
+
- `/stop` no longer cancels the messages you send after it. It released the room the moment it killed the run, before that run had unwound, so a message sent in those seconds started, met the stop flag at its pre-spawn checkpoint and was cancelled "before the provider started" — three messages lost after one `/stop` on 24 Sep. The room now stays busy until the stopped run's own unwind releases it and drains the queue (a 30 s backstop covers a child that never reports its exit), and the stop flag names the run it stops, so it can never cancel another.
|
|
17
|
+
- "Queued." now says what the message is waiting on once the wait passes two minutes, e.g. `Queued behind the watch "Phone alarm" (running 42 min). /stop cancels it.`
|
|
18
|
+
- A watch that has triggered but waited 15 minutes behind a busy room posts one plain line per occurrence, so an alarm is never hidden for hours (24 Sep: 2 h and 7.5 h, with nothing in the chat).
|
|
19
|
+
|
|
3
20
|
## 3.4.22
|
|
4
21
|
|
|
5
22
|
Supersedes 3.4.21, which was tagged but never published: its build failed because `test-upgrade-path.js` was wired into the test chain without being committed. Nothing shipped from that tag.
|
package/bin/schedule.js
CHANGED
|
@@ -8,7 +8,8 @@ Background scheduling — manage wakeups and crons.
|
|
|
8
8
|
|
|
9
9
|
Wakeups (one-shot, resumes the same conversation):
|
|
10
10
|
open-claudia schedule-wakeup <when> "<prompt>" [--label "<label>"]
|
|
11
|
-
<when>: duration (e.g. 30s, 5m, 2h, 1d) OR an ISO datetime (e.g. 2026-05-16T15:00:00Z)
|
|
11
|
+
<when>: duration (e.g. 30s, 5m, 2h, 1d) OR an ISO datetime (e.g. 2026-05-16T15:00:00Z),
|
|
12
|
+
at most 10 years out
|
|
12
13
|
|
|
13
14
|
Crons (recurring):
|
|
14
15
|
open-claudia cron-add "<5-field cron>" "<prompt>" [--label "<label>"] [--project <name>]
|
|
@@ -25,6 +26,8 @@ Watches (silent background monitor — polls a shell check, wakes you only on a
|
|
|
25
26
|
datetime. Default 2h. A watch that reaches its timeout without
|
|
26
27
|
the check ever passing FIRES anyway on the next poll, with the
|
|
27
28
|
prompt marked "Watch expired" — no watch can end silently.
|
|
29
|
+
A check that times out (30s) 3 times in a row wakes the agent once, marked
|
|
30
|
+
"Watch check timing out"; the watch keeps polling.
|
|
28
31
|
open-claudia watch-list # watches on this channel + master switch state
|
|
29
32
|
open-claudia watch-remove <id> # remove a watch by id
|
|
30
33
|
open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
|
|
@@ -37,6 +40,10 @@ switching providers starts a fresh thread.
|
|
|
37
40
|
`;
|
|
38
41
|
|
|
39
42
|
const DUR = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/i;
|
|
43
|
+
// Mirrors core/scheduler MAX_WAKEUP_HORIZON_MS: a wakeup further out than this
|
|
44
|
+
// is a typo or a mis-parsed <when> (one was set 1273 years out).
|
|
45
|
+
const MAX_WAKEUP_YEARS = 10;
|
|
46
|
+
const MAX_WAKEUP_HORIZON_MS = MAX_WAKEUP_YEARS * 365.25 * 24 * 60 * 60 * 1000;
|
|
40
47
|
|
|
41
48
|
function parseWhen(s) {
|
|
42
49
|
if (!s) return null;
|
|
@@ -63,13 +70,41 @@ function takeFlag(args, name) {
|
|
|
63
70
|
return v;
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
// Where a job stands once it has fired. A fired job used to stay listed as a
|
|
74
|
+
// future one ("fires 2028-08-01", "EXPIRED (fires on next poll)") while its
|
|
75
|
+
// delivery was queued, running or dead (17 and 21 Sep).
|
|
76
|
+
function jobProgress(j) {
|
|
77
|
+
const since = j.check && j.watchTriggeredOccurrenceId && j.watchTriggeredOccurrenceId === j.occurrenceId && Number(j.watchTriggeredAt) > 0
|
|
78
|
+
? `TRIGGERED ${new Date(Number(j.watchTriggeredAt)).toISOString()}, ` : "";
|
|
79
|
+
const state = {
|
|
80
|
+
running: "delivery running",
|
|
81
|
+
deferred: "delivery waiting for a busy room",
|
|
82
|
+
failed: `delivery failed (${j.lastError?.code || "error"}), retrying`,
|
|
83
|
+
"dead-letter": `delivery failed (${j.lastError?.code || "error"}), DEAD: will not fire`,
|
|
84
|
+
}[j.status];
|
|
85
|
+
return state ? `${since}${state}` : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
function fmtJob(j) {
|
|
67
89
|
const when = j.kind === "wakeup"
|
|
68
90
|
? `fires ${new Date(j.fireAt).toISOString()}`
|
|
69
91
|
: `schedule "${j.schedule}"`;
|
|
70
92
|
const proj = j.project ? ` [${j.project}]` : "";
|
|
71
93
|
const src = j.source ? ` (${j.source})` : "";
|
|
72
|
-
|
|
94
|
+
const progress = jobProgress(j);
|
|
95
|
+
return `${j.id} ${j.kind} ${j.label || "(no label)"}${proj}${src} ${when}${progress ? ` [${progress}]` : ""}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fmtWatch(w, now = Date.now()) {
|
|
99
|
+
const mode = w.repeat ? "repeating" : "one-shot";
|
|
100
|
+
const progress = jobProgress(w);
|
|
101
|
+
const expiry = progress ? ` ${progress}`
|
|
102
|
+
: Number(w.expiresAt) > 0
|
|
103
|
+
? (now >= Number(w.expiresAt)
|
|
104
|
+
? " EXPIRED (fires on next poll)"
|
|
105
|
+
: ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
|
|
106
|
+
: " no expiry (pre-timeout watch)";
|
|
107
|
+
return `${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`;
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
async function runScheduleWakeup(args) {
|
|
@@ -82,6 +117,11 @@ async function runScheduleWakeup(args) {
|
|
|
82
117
|
console.error(`Could not parse <when>: "${rest[0]}". Use a duration (5m, 2h, 1d) or ISO datetime.`);
|
|
83
118
|
process.exit(2);
|
|
84
119
|
}
|
|
120
|
+
if (when - Date.now() > MAX_WAKEUP_HORIZON_MS) {
|
|
121
|
+
const at = new Date(when);
|
|
122
|
+
console.error(`<when> "${rest[0]}" is ${Number.isNaN(at.getTime()) ? "past any valid date" : at.toISOString()}, more than ${MAX_WAKEUP_YEARS} years out. Check the date; for a far reminder use a yearly cron-add.`);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
85
125
|
const prompt = rest.slice(1).join(" ");
|
|
86
126
|
if (!prompt) { console.error("Missing prompt."); process.exit(2); }
|
|
87
127
|
try {
|
|
@@ -178,15 +218,7 @@ async function runWatchList() {
|
|
|
178
218
|
const list = res.watches || [];
|
|
179
219
|
console.log(`Watches: master switch is ${res.enabled ? "ON" : "OFF"}.`);
|
|
180
220
|
if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
|
|
181
|
-
for (const w of list)
|
|
182
|
-
const mode = w.repeat ? "repeating" : "one-shot";
|
|
183
|
-
const expiry = Number(w.expiresAt) > 0
|
|
184
|
-
? (Date.now() >= Number(w.expiresAt)
|
|
185
|
-
? " EXPIRED (fires on next poll)"
|
|
186
|
-
: ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
|
|
187
|
-
: " no expiry (pre-timeout watch)";
|
|
188
|
-
console.log(`${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`);
|
|
189
|
-
}
|
|
221
|
+
for (const w of list) console.log(fmtWatch(w));
|
|
190
222
|
process.exit(0);
|
|
191
223
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
192
224
|
}
|
|
@@ -211,6 +243,8 @@ async function runWatchToggle(enabled) {
|
|
|
211
243
|
|
|
212
244
|
module.exports = {
|
|
213
245
|
HELP,
|
|
246
|
+
fmtJob,
|
|
247
|
+
fmtWatch,
|
|
214
248
|
runScheduleWakeup,
|
|
215
249
|
runCronAdd,
|
|
216
250
|
runCronList,
|
package/core/handlers.js
CHANGED
|
@@ -20,7 +20,7 @@ const {
|
|
|
20
20
|
getProviderSettings, resolveProviderSettings,
|
|
21
21
|
getProjectSessions, getLastProjectSession, userOwnsProviderSession,
|
|
22
22
|
createSessionCallbackToken, linkIdentity,
|
|
23
|
-
roomSettingsFor, setRoomSetting,
|
|
23
|
+
roomSettingsFor, setRoomSetting, cancelFlagFor,
|
|
24
24
|
} = require("./state");
|
|
25
25
|
const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
|
|
26
26
|
const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
|
|
@@ -1642,6 +1642,17 @@ register({
|
|
|
1642
1642
|
},
|
|
1643
1643
|
});
|
|
1644
1644
|
|
|
1645
|
+
// How long /stop waits for a stopped child to report its exit before it
|
|
1646
|
+
// releases the room anyway (SIGKILL follows SIGTERM after 3 s).
|
|
1647
|
+
const STOP_RELEASE_MS = 30 * 1000;
|
|
1648
|
+
function releaseStuckStop(state, proc, stopped) {
|
|
1649
|
+
if (state.runningProcess !== proc) return;
|
|
1650
|
+
if (proc.exitCode != null || proc.signalCode != null) return; // exited: its unwind releases the room
|
|
1651
|
+
console.warn(`[stop] child ${proc.pid} never reported its exit; releasing the room`);
|
|
1652
|
+
state.runningProcess = null;
|
|
1653
|
+
if (state.cancelRequested === stopped) state.cancelRequested = false;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1645
1656
|
register({
|
|
1646
1657
|
name: "stop", description: "Cancel running task",
|
|
1647
1658
|
handler: async (env) => {
|
|
@@ -1655,14 +1666,23 @@ register({
|
|
|
1655
1666
|
return n ? ` ${n} queued message${n === 1 ? "" : "s"} will run next.` : "";
|
|
1656
1667
|
};
|
|
1657
1668
|
if (state.runningProcess) {
|
|
1658
|
-
const
|
|
1669
|
+
const proc = state.runningProcess;
|
|
1670
|
+
const pid = proc.pid;
|
|
1659
1671
|
const { killProcessTree } = require("./process-tree");
|
|
1660
1672
|
// Quiet unwind: deliverForegroundResult consumes this flag to suppress
|
|
1661
|
-
// the "run failed" diagnostic the SIGTERM exit would otherwise emit.
|
|
1662
|
-
|
|
1673
|
+
// the "run failed" diagnostic the SIGTERM exit would otherwise emit. It
|
|
1674
|
+
// names the run it stops, so it can never cancel a different one.
|
|
1675
|
+
const stopped = cancelFlagFor(state);
|
|
1676
|
+
state.cancelRequested = stopped;
|
|
1663
1677
|
killProcessTree(pid, "SIGTERM");
|
|
1664
1678
|
setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
|
|
1665
|
-
|
|
1679
|
+
// The room stays busy until the stopped run has unwound: its own
|
|
1680
|
+
// finally releases the lock, clears this flag and drains the queue.
|
|
1681
|
+
// Releasing it here let a message sent in those seconds start, meet the
|
|
1682
|
+
// flag and be cancelled "before the provider started" (24 Sep: three
|
|
1683
|
+
// messages lost after one /stop). Backstop: a child that never reports
|
|
1684
|
+
// its exit must not pin the room.
|
|
1685
|
+
setTimeout(() => releaseStuckStop(state, proc, stopped), STOP_RELEASE_MS).unref?.();
|
|
1666
1686
|
// Stop pending preview flushes but keep statusMessageId/Channel: the
|
|
1667
1687
|
// killed run's delivery unwind edits the preview to drop its footer.
|
|
1668
1688
|
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
@@ -1675,7 +1695,7 @@ register({
|
|
|
1675
1695
|
} else if (state.preparingRun) {
|
|
1676
1696
|
// Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
|
|
1677
1697
|
// the runner's pre-spawn shouldAbort checkpoint bails before spawning.
|
|
1678
|
-
state.cancelRequested =
|
|
1698
|
+
state.cancelRequested = cancelFlagFor(state);
|
|
1679
1699
|
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; state.typingTick = null; }
|
|
1680
1700
|
try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
|
|
1681
1701
|
await sideCancellation;
|
package/core/loopback.js
CHANGED
|
@@ -58,6 +58,14 @@ function reply(res, code, body) {
|
|
|
58
58
|
res.end(data);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// An adapter's send* resolves false after logging its own error, so the JSON
|
|
62
|
+
// used to say only {"ok":false} (10 Sep: every `say` failed like that through
|
|
63
|
+
// a Kazee outage). Name the step that failed; the adapter's message may carry
|
|
64
|
+
// request details, so it stays in bot.log.
|
|
65
|
+
function undeliveredReason(adapter, what) {
|
|
66
|
+
return `${adapter.type || adapter.id} adapter did not deliver the ${what}: the channel rejected it or was unreachable (details in bot.log)`;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
function authOk(req) {
|
|
62
70
|
return (req.headers["authorization"] || "") === `Bearer ${token}`;
|
|
63
71
|
}
|
|
@@ -154,7 +162,7 @@ async function handleSend(req, res, url, kind) {
|
|
|
154
162
|
} finally {
|
|
155
163
|
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
156
164
|
}
|
|
157
|
-
return reply(res, ok
|
|
165
|
+
return ok ? reply(res, 200, { ok }) : reply(res, 500, { ok, error: undeliveredReason(adapter, kind === "send-voice" ? "voice note" : kind.slice(5)) });
|
|
158
166
|
}
|
|
159
167
|
|
|
160
168
|
async function handleJson(req, res, url, kind) {
|
|
@@ -215,7 +223,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
215
223
|
return reply(res, 500, { error: `voice send failed: ${e.message}` });
|
|
216
224
|
}
|
|
217
225
|
try { fs.unlinkSync(voicePath); } catch (e) {}
|
|
218
|
-
return reply(res, ok
|
|
226
|
+
return ok ? reply(res, 200, { ok }) : reply(res, 500, { ok, error: undeliveredReason(adapter, "voice note") });
|
|
219
227
|
}
|
|
220
228
|
|
|
221
229
|
if (kind === "tts") {
|
|
@@ -271,6 +279,9 @@ async function handleJson(req, res, url, kind) {
|
|
|
271
279
|
if (kind === "schedule-wakeup") {
|
|
272
280
|
const fireAt = Number(payload.fireAt);
|
|
273
281
|
if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
|
|
282
|
+
if (fireAt - Date.now() > scheduler.MAX_WAKEUP_HORIZON_MS) {
|
|
283
|
+
return reply(res, 400, { error: "fireAt is more than 10 years out; check the date (a far reminder belongs in a yearly cron)" });
|
|
284
|
+
}
|
|
274
285
|
if (!payload.prompt) return reply(res, 400, { error: "missing prompt" });
|
|
275
286
|
const job = scheduler.addJob({
|
|
276
287
|
kind: "wakeup",
|
package/core/runner.js
CHANGED
|
@@ -13,7 +13,7 @@ const {
|
|
|
13
13
|
} = require("./config");
|
|
14
14
|
const {
|
|
15
15
|
currentState, saveState, recordSession,
|
|
16
|
-
resetSessionUsage, acquireRunLock, getActiveProvider, getProjectKey,
|
|
16
|
+
resetSessionUsage, acquireRunLock, cancelTargets, getActiveProvider, getProjectKey,
|
|
17
17
|
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
|
|
18
18
|
resolveProviderSettings,
|
|
19
19
|
} = require("./state");
|
|
@@ -1698,9 +1698,10 @@ async function deliverForegroundResult(result, runContext, state = currentState(
|
|
|
1698
1698
|
// /stop unwind: the SIGTERM (or pre-spawn abort) that follows a user cancel
|
|
1699
1699
|
// is not a failure worth announcing — /stop already replied "Cancelled.".
|
|
1700
1700
|
// The flag is consumed here (and re-cleared on the run's unwind) so it can
|
|
1701
|
-
// never suppress a later run's genuine failure.
|
|
1702
|
-
|
|
1703
|
-
|
|
1701
|
+
// never suppress a later run's genuine failure; it names the run it stopped.
|
|
1702
|
+
const stoppedHere = cancelTargets(state.cancelRequested, runContext);
|
|
1703
|
+
if (!result.ok && (stoppedHere || result.error?.code === "RUN_CANCELLED")) {
|
|
1704
|
+
if (stoppedHere) state.cancelRequested = false;
|
|
1704
1705
|
return { ok: true, suppressed: "cancelled" };
|
|
1705
1706
|
}
|
|
1706
1707
|
const diagnostic = providerRunDiagnostic(result, runContext);
|
|
@@ -1871,6 +1872,18 @@ function settleImmediateEffects(result, runContext, { state, store, capture }) {
|
|
|
1871
1872
|
const STREAM_PREVIEW_THROTTLE_MS = 2500;
|
|
1872
1873
|
const STREAM_PREVIEW_FOOTER = "\n\n⏳ working — /stop to cancel";
|
|
1873
1874
|
|
|
1875
|
+
// Runs whose interim narration (and, under /verbose, thinking) streams into
|
|
1876
|
+
// the chat: the person's own turns, and scheduled wake-ups (watches, wakeups,
|
|
1877
|
+
// crons). A scheduled run answers in the same room and settles through
|
|
1878
|
+
// deliverForegroundResult, which cleans the preview up exactly as for a
|
|
1879
|
+
// foreground turn; without it a watch-woken turn showed only "thinking..."
|
|
1880
|
+
// until the final answer. Background runs (dream, review, recall, learn,
|
|
1881
|
+
// compaction, subagents, side chats, silent runs) stay silent.
|
|
1882
|
+
const STREAM_PREVIEW_PURPOSES = new Set(["foreground", "scheduled"]);
|
|
1883
|
+
function streamPreviewAllowed(runContext) {
|
|
1884
|
+
return STREAM_PREVIEW_PURPOSES.has(runContext?.purpose);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1874
1887
|
// Edited-in-place interim preview: each completed text block the provider
|
|
1875
1888
|
// emits mid-run is appended to one throttled status message, so the user sees
|
|
1876
1889
|
// the narration as it happens instead of silence. finalizeStreamPreview
|
|
@@ -1911,7 +1924,7 @@ function createStreamPreview({ state, store }) {
|
|
|
1911
1924
|
};
|
|
1912
1925
|
|
|
1913
1926
|
const appendChunk = (chunk, runContext) => {
|
|
1914
|
-
if (!enabled || runContext
|
|
1927
|
+
if (!enabled || !streamPreviewAllowed(runContext) || state.cancelRequested) return;
|
|
1915
1928
|
state.streamBuffer = state.streamBuffer ? `${state.streamBuffer}\n\n${chunk}` : chunk;
|
|
1916
1929
|
if (state.streamBuffer.length > 20000) state.streamBuffer = state.streamBuffer.slice(-16000);
|
|
1917
1930
|
if (!state.streamInterval) state.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
|
|
@@ -1983,7 +1996,7 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1983
1996
|
},
|
|
1984
1997
|
shouldAbort(runContext, { capture: isCapture } = {}) {
|
|
1985
1998
|
if (isCapture || !ownsAdmissionLock) return false;
|
|
1986
|
-
if (!state.cancelRequested) return false;
|
|
1999
|
+
if (!cancelTargets(state.cancelRequested, runContext)) return false;
|
|
1987
2000
|
state.cancelRequested = false;
|
|
1988
2001
|
return true;
|
|
1989
2002
|
},
|
|
@@ -2197,7 +2210,7 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2197
2210
|
// A cancel flag that survived this run (e.g. /stop raced a run that
|
|
2198
2211
|
// finished successfully anyway) must not leak into the next turn, where
|
|
2199
2212
|
// it would falsely abort a queued run or mute a genuine failure.
|
|
2200
|
-
if (ownsAdmissionLock) state.cancelRequested = false;
|
|
2213
|
+
if (ownsAdmissionLock && cancelTargets(state.cancelRequested, runContext)) state.cancelRequested = false;
|
|
2201
2214
|
if (tracksMainRun && state.activeRunContext === runContext) state.activeRunContext = null;
|
|
2202
2215
|
stopTyping();
|
|
2203
2216
|
}
|
|
@@ -2265,6 +2278,21 @@ function admitRunContext(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2265
2278
|
});
|
|
2266
2279
|
}
|
|
2267
2280
|
|
|
2281
|
+
// What a message queued behind a long run is waiting for. A short wait is just
|
|
2282
|
+
// "Queued."; after two minutes it says what is running and how to stop it, so a
|
|
2283
|
+
// busy room never looks like a dead one (24 Sep: an hour behind a watch run).
|
|
2284
|
+
const QUEUED_DETAIL_AFTER_MS = 2 * 60 * 1000;
|
|
2285
|
+
function queuedNotice(activeRunContext, at = Date.now()) {
|
|
2286
|
+
const since = Number(activeRunContext?.admittedAt);
|
|
2287
|
+
if (!Number.isFinite(since) || at - since < QUEUED_DETAIL_AFTER_MS) return "Queued.";
|
|
2288
|
+
const minutes = Math.floor((at - since) / 60000);
|
|
2289
|
+
const header = String(activeRunContext.userPrompt || "").match(/^\[(Watch triggered|Watch expired|Watch check timing out|Scheduled cron|Scheduled wakeup) "([^"]*)"/);
|
|
2290
|
+
const kind = header ? { "Watch triggered": "watch", "Watch expired": "watch", "Watch check timing out": "watch", "Scheduled cron": "scheduled job", "Scheduled wakeup": "wakeup" }[header[1]] : null;
|
|
2291
|
+
const label = header && header[2].length > 60 ? `${header[2].slice(0, 59).trimEnd()}…` : header?.[2];
|
|
2292
|
+
const what = kind ? `the ${kind} "${label}"` : "an earlier message";
|
|
2293
|
+
return `Queued behind ${what} (running ${minutes} min). /stop cancels it.`;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2268
2296
|
function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
2269
2297
|
const state = currentState();
|
|
2270
2298
|
const store = chatContext.getStore() || null;
|
|
@@ -2294,7 +2322,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2294
2322
|
// task — noise. On a live call, send() is the TTS line: "Queued." would be
|
|
2295
2323
|
// spoken to the caller. Both stay quiet and just deliver the real reply.
|
|
2296
2324
|
if (currentAdapter()?.type !== "spaces" && !statusChatterMuted()) {
|
|
2297
|
-
send(state.isCompacting ? "Compacting context, will pick this up next…" :
|
|
2325
|
+
send(state.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(state.activeRunContext), { replyTo: replyToMsgId }).catch(() => {});
|
|
2298
2326
|
}
|
|
2299
2327
|
return deferred.promise;
|
|
2300
2328
|
}
|
|
@@ -2389,7 +2417,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
|
|
|
2389
2417
|
}
|
|
2390
2418
|
|
|
2391
2419
|
module.exports = {
|
|
2420
|
+
queuedNotice,
|
|
2392
2421
|
admitRunContext,
|
|
2422
|
+
streamPreviewAllowed,
|
|
2393
2423
|
capturedTranscriptMetadata,
|
|
2394
2424
|
decorateForegroundInvocation,
|
|
2395
2425
|
createRunnerService,
|