openmausbot 0.1.83 → 0.1.84
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/assets/{index-DSvqfBOf.js → index-BbU5REzd.js} +154 -153
- package/dist/assets/{index-CKysBq-V.css → index-CLGfYlx_.css} +1 -1
- package/dist/assets/{index-nqTFZTxg.js → index-DNa2umw-.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/drivers/agents-proxy.js +26 -0
- package/dist-server/index.js +659 -94
- package/dist-server/openmausbot.js +37 -8
- package/dist-server/pair-cli.js +37 -8
- package/dist-server/server/checkpoints.js +9 -5
- package/dist-server/server/chief-of-staff.js +3 -0
- package/dist-server/server/computer-wait.js +33 -0
- package/dist-server/server/config.js +25 -1
- package/dist-server/server/delta-context.js +258 -0
- package/dist-server/server/drivers/agents-proxy.js +28 -0
- package/dist-server/server/drivers/claude.js +22 -5
- package/dist-server/server/drivers/codex.js +13 -5
- package/dist-server/server/incidents.js +104 -0
- package/dist-server/server/index.js +353 -51
- package/dist-server/server/notify.js +3 -1
- package/dist-server/server/store.js +18 -1
- package/package.json +1 -1
|
@@ -64,6 +64,7 @@ import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from
|
|
|
64
64
|
import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
|
|
65
65
|
import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
66
66
|
import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
|
|
67
|
+
import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
|
|
67
68
|
/** A session_read answer competes with the transcript for the context
|
|
68
69
|
* window; a computer-use turn's output can run to hundreds of KB. */
|
|
69
70
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
@@ -83,6 +84,7 @@ import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, toWir
|
|
|
83
84
|
import * as tts from "./tts/index.js";
|
|
84
85
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
85
86
|
import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
|
|
87
|
+
import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
|
|
86
88
|
import { extractTurnImages } from "./turn-images.js";
|
|
87
89
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
88
90
|
import { TurnResources, workspaceResource } from "./turn-resources.js";
|
|
@@ -138,6 +140,7 @@ import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
|
|
|
138
140
|
import { shouldMountLocalComputer } from "./local-routing.js";
|
|
139
141
|
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
140
142
|
import { startAutoVmClaim } from "./auto-vm-claims.js";
|
|
143
|
+
import { computerFreeText, computerStillBusyText, computerWaitEndedText, computerWaitingText } from "./computer-wait.js";
|
|
141
144
|
import { modelContextWindow } from "./model-context-window.js";
|
|
142
145
|
import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
143
146
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
@@ -625,6 +628,9 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
625
628
|
const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
|
|
626
629
|
turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
|
|
627
630
|
let waitingMessage;
|
|
631
|
+
// Who holds the desktop, as the chip and the give-up error name them: a
|
|
632
|
+
// bot running a titled thread, or a room. Read once, when the wait begins.
|
|
633
|
+
let holder;
|
|
628
634
|
const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
|
|
629
635
|
try {
|
|
630
636
|
while (true) {
|
|
@@ -636,23 +642,25 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
636
642
|
const blocker = turnResources.blocker(resource, owner);
|
|
637
643
|
const holderBot = blocker && store.botByThread(blocker.threadId);
|
|
638
644
|
const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
|
|
639
|
-
const
|
|
640
|
-
|
|
645
|
+
const holderRoom = !holderBot && blocker ? store.groupByThread(blocker.threadId) : null;
|
|
646
|
+
holder = holderBot
|
|
647
|
+
? { name: holderBot.name, ...(holderTask?.title ? { task: holderTask.title } : {}) }
|
|
648
|
+
: holderRoom ? { name: holderRoom.name } : undefined;
|
|
641
649
|
waitingMessage = store.appendMessage(owner.threadId, {
|
|
642
650
|
role: "bot", kind: "activity",
|
|
643
|
-
tool: { name:
|
|
651
|
+
tool: { name: computerWaitingText(holder) },
|
|
644
652
|
...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
|
|
645
653
|
});
|
|
646
654
|
}
|
|
647
655
|
if (Date.now() >= deadline)
|
|
648
|
-
throw new Error(
|
|
656
|
+
throw new Error(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
|
|
649
657
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
650
658
|
}
|
|
651
659
|
}
|
|
652
660
|
finally {
|
|
653
661
|
if (waitingMessage)
|
|
654
662
|
store.patchMessage(owner.threadId, waitingMessage.id, {
|
|
655
|
-
tool: { name: active() && turnResources.owns(resource, owner) ?
|
|
663
|
+
tool: { name: active() && turnResources.owns(resource, owner) ? computerFreeText() : computerWaitEndedText(), ok: true },
|
|
656
664
|
});
|
|
657
665
|
}
|
|
658
666
|
turnResourceOwners.set(owner.threadId, owner);
|
|
@@ -3001,6 +3009,10 @@ const watchdog = new TurnWatchdog({
|
|
|
3001
3009
|
kind: "activity",
|
|
3002
3010
|
tool: { name: `error: no activity for ${minutes} minutes — the turn was stopped`, ok: false },
|
|
3003
3011
|
});
|
|
3012
|
+
// a routine's stall reports through its own failure path
|
|
3013
|
+
if (bot && routineRun?.target !== "bot") {
|
|
3014
|
+
reportIncident({ kind: "stalled", bot, threadId: turn.threadId, detail: `no activity for ${minutes} minutes — the turn was stopped` });
|
|
3015
|
+
}
|
|
3004
3016
|
settleDirectFollowup(stalledGeneration);
|
|
3005
3017
|
finalizeDelegationWatch(turn.threadId, false, "", "Delegated turn stalled and was stopped");
|
|
3006
3018
|
turnUsage.delete(turn.threadId);
|
|
@@ -3039,8 +3051,7 @@ const watchdog = new TurnWatchdog({
|
|
|
3039
3051
|
const currentBot = store.bot(turn.botId);
|
|
3040
3052
|
if (currentBot?.busy) {
|
|
3041
3053
|
stopScreenPoller(currentBot.id, turn.threadId);
|
|
3042
|
-
|
|
3043
|
-
activeVpsThreads.delete(currentBot.id);
|
|
3054
|
+
vpsThreadEnded(currentBot.id, turn.threadId);
|
|
3044
3055
|
if (store.taskByThread(currentBot.id, turn.threadId))
|
|
3045
3056
|
store.setTaskActivity(currentBot.id, turn.threadId, "idle");
|
|
3046
3057
|
else
|
|
@@ -3233,7 +3244,25 @@ function noteLocalVmSeen(target, status) {
|
|
|
3233
3244
|
let localVmImageBusy = false;
|
|
3234
3245
|
let localVmProvisionBusy = false;
|
|
3235
3246
|
let localVmModeChangeBusy = false;
|
|
3247
|
+
/** Threads running with a bot's VPS computer mounted, per bot. Several run
|
|
3248
|
+
* at once — only the desktop lease is exclusive, and it is claimed on the
|
|
3249
|
+
* first screen call (see the VPS mount in dispatch) — so the alias and
|
|
3250
|
+
* backend guards ask "any thread?", and a settling thread removes only
|
|
3251
|
+
* itself, never a sibling still running. */
|
|
3236
3252
|
const activeVpsThreads = new Map();
|
|
3253
|
+
function vpsThreadStarted(botId, threadId) {
|
|
3254
|
+
const threads = activeVpsThreads.get(botId) ?? new Set();
|
|
3255
|
+
threads.add(threadId);
|
|
3256
|
+
activeVpsThreads.set(botId, threads);
|
|
3257
|
+
}
|
|
3258
|
+
function vpsThreadEnded(botId, threadId) {
|
|
3259
|
+
const threads = activeVpsThreads.get(botId);
|
|
3260
|
+
if (!threads)
|
|
3261
|
+
return;
|
|
3262
|
+
threads.delete(threadId);
|
|
3263
|
+
if (!threads.size)
|
|
3264
|
+
activeVpsThreads.delete(botId);
|
|
3265
|
+
}
|
|
3237
3266
|
const boxLifecycleBusyBots = new Set();
|
|
3238
3267
|
// A refresh is a reader, not a lifecycle change. Keep its reservation until
|
|
3239
3268
|
// the provider settles even if the HTTP client leaves, and share it on retry.
|
|
@@ -3785,6 +3814,8 @@ bus.subscribe((event) => {
|
|
|
3785
3814
|
pushMessage({ role: "bot", kind: "text", text: coordinatorVisibleText, turnId: completedTurnId });
|
|
3786
3815
|
lastReply.set(event.threadId, coordinatorVisibleText);
|
|
3787
3816
|
}
|
|
3817
|
+
if (bot)
|
|
3818
|
+
handoffs.onEvent(event);
|
|
3788
3819
|
switch (event.type) {
|
|
3789
3820
|
case "session.started":
|
|
3790
3821
|
if (bot && event.sessionId && event.providerInstanceId) {
|
|
@@ -4109,6 +4140,13 @@ bus.subscribe((event) => {
|
|
|
4109
4140
|
store.markTerminalAssistantMessage(event.threadId, completedTurnId);
|
|
4110
4141
|
const reply = lastReply.get(event.threadId) ?? "";
|
|
4111
4142
|
lastReply.delete(event.threadId);
|
|
4143
|
+
// A run that broke — not one the person stopped, and not a routine's,
|
|
4144
|
+
// which reports through its own failure path — is the Chief's to see.
|
|
4145
|
+
if (!event.ok && event.stopReason !== "interrupted" && !routines?.runForThread(event.threadId)) {
|
|
4146
|
+
const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
4147
|
+
if (broken)
|
|
4148
|
+
reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
|
|
4149
|
+
}
|
|
4112
4150
|
const lastReported = turnUsage.get(event.threadId);
|
|
4113
4151
|
turnUsage.delete(event.threadId);
|
|
4114
4152
|
turnContext.delete(event.threadId);
|
|
@@ -4126,8 +4164,7 @@ bus.subscribe((event) => {
|
|
|
4126
4164
|
const ownsResources = resourceOwner &&
|
|
4127
4165
|
turnResourceOwners.get(event.threadId)?.generation === resourceOwner.generation;
|
|
4128
4166
|
if (ownsResources) {
|
|
4129
|
-
|
|
4130
|
-
activeVpsThreads.delete(bot.id);
|
|
4167
|
+
vpsThreadEnded(bot.id, event.threadId);
|
|
4131
4168
|
releaseLocalVmThread(event.threadId);
|
|
4132
4169
|
}
|
|
4133
4170
|
releaseTurnResources(resourceOwner);
|
|
@@ -4356,6 +4393,93 @@ function wakeDelegationSource(source, threadId, targetName, failureReason, routi
|
|
|
4356
4393
|
}
|
|
4357
4394
|
dispatchDelegationWake(source.id, threadId, targetName, failureReason, routineRunId);
|
|
4358
4395
|
}
|
|
4396
|
+
// ── incidents: a broken run reaches the Chief of Staff ──────────────────
|
|
4397
|
+
// A failed, stalled or unstartable run used to leave one chip in the thread
|
|
4398
|
+
// it died in and nothing anywhere else; the person found it hours later,
|
|
4399
|
+
// from a phone, by opening the desktop and reading every thread. The team
|
|
4400
|
+
// already has a role for this — the Chief coordinates the section — so the
|
|
4401
|
+
// incident becomes a turn of the Chief's, in its "Team incidents" thread,
|
|
4402
|
+
// with a link to the broken thread and retry_thread to act on it. The person
|
|
4403
|
+
// reads one place. Policy in server/incidents.ts.
|
|
4404
|
+
const incidentLedger = new IncidentLedger();
|
|
4405
|
+
/** What the broken thread was about: the last line the person (or the
|
|
4406
|
+
* requester) sent there, and the last thing the bot said. */
|
|
4407
|
+
function incidentContext(threadId) {
|
|
4408
|
+
const messages = [...store.messagesFor(threadId)].reverse();
|
|
4409
|
+
return {
|
|
4410
|
+
lastRequest: messages.find((message) => message.role === "user" && message.kind === "text" && message.text)?.text ?? null,
|
|
4411
|
+
lastReply: messages.find((message) => message.role === "bot" && message.kind === "text" && message.text)?.text ?? null,
|
|
4412
|
+
};
|
|
4413
|
+
}
|
|
4414
|
+
function reportIncident(input) {
|
|
4415
|
+
const { bot, threadId } = input;
|
|
4416
|
+
const task = store.taskByThread(bot.id, threadId);
|
|
4417
|
+
// A thread another bot opened and is watching is that bot's to handle:
|
|
4418
|
+
// the delegator is woken with the failure already (wakeDelegationSource).
|
|
4419
|
+
if (task?.openedBy?.delegationId || delegationWatch.has(threadId) || roomHandoffs.activeDirect(threadId))
|
|
4420
|
+
return;
|
|
4421
|
+
const group = store.groupByThread(threadId);
|
|
4422
|
+
const incident = {
|
|
4423
|
+
kind: input.kind,
|
|
4424
|
+
bot,
|
|
4425
|
+
threadId,
|
|
4426
|
+
title: task?.title ?? null,
|
|
4427
|
+
room: group?.name ?? null,
|
|
4428
|
+
detail: redactSecretsInText(input.detail),
|
|
4429
|
+
...incidentContext(threadId),
|
|
4430
|
+
};
|
|
4431
|
+
const count = incidentLedger.note(threadId);
|
|
4432
|
+
// a crash loop is one incident, not a storm
|
|
4433
|
+
if (count.muted)
|
|
4434
|
+
return;
|
|
4435
|
+
const chief = chiefForBot(store.bots, bot);
|
|
4436
|
+
// A run that could not start and a failed routine have already buzzed
|
|
4437
|
+
// the person (turn-failed, routine-failed) by the time they get here; a
|
|
4438
|
+
// failure or stall mid-run has not. One notification per failure, never two.
|
|
4439
|
+
const alreadyNotified = input.kind === "could-not-start" || input.kind === "routine-failed";
|
|
4440
|
+
const tellThePerson = () => {
|
|
4441
|
+
if (alreadyNotified)
|
|
4442
|
+
return;
|
|
4443
|
+
notify(buildNotification("incident", bot, threadId, incidentChip(incident), {
|
|
4444
|
+
avatarUrl: bot.avatarUrl,
|
|
4445
|
+
...(group ? { group: { id: group.id, name: group.name } } : {}),
|
|
4446
|
+
}));
|
|
4447
|
+
};
|
|
4448
|
+
// no Chief on duty, or the Chief itself broke: the person is next
|
|
4449
|
+
if (!chief) {
|
|
4450
|
+
tellThePerson();
|
|
4451
|
+
return;
|
|
4452
|
+
}
|
|
4453
|
+
const incidents = store.tasks(chief.id).find((candidate) => candidate.title === INCIDENTS_THREAD_TITLE && !candidate.archivedAt)
|
|
4454
|
+
?? store.createTask(chief.id, INCIDENTS_THREAD_TITLE, false, undefined, { botId: chief.id, name: chief.name, at: Date.now() });
|
|
4455
|
+
if (!incidents || incidents.threadId === threadId) {
|
|
4456
|
+
tellThePerson();
|
|
4457
|
+
return;
|
|
4458
|
+
}
|
|
4459
|
+
store.appendMessage(incidents.threadId, {
|
|
4460
|
+
role: "bot",
|
|
4461
|
+
kind: "activity",
|
|
4462
|
+
tool: { name: incidentChip(incident), ok: false },
|
|
4463
|
+
threadRef: { botId: bot.id, threadId, title: task?.title ?? (group ? group.name : `${bot.name}'s conversation`) },
|
|
4464
|
+
});
|
|
4465
|
+
const text = incidentText(incident, count);
|
|
4466
|
+
// the report carries the broken bot's name as its provenance: it is about
|
|
4467
|
+
// that bot's work and nobody was at the keyboard
|
|
4468
|
+
const peerAsk = { botId: bot.id, name: bot.name, unattended: true };
|
|
4469
|
+
if (botAtThreadCapacity(chief.id) || activeGroupTurnForBot(chief.id)) {
|
|
4470
|
+
queueSteeredMessage(chief.id, incidents.threadId, text, { reason: "capacity", unattended: true, peerAsk });
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
void startTurn(chief.id, text, { threadId: incidents.threadId, unattended: true, peerAsk }).catch((error) => {
|
|
4474
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
4475
|
+
store.appendMessage(incidents.threadId, {
|
|
4476
|
+
role: "bot",
|
|
4477
|
+
kind: "activity",
|
|
4478
|
+
tool: { name: `error: the incident could not reach ${chief.name} — ${why.slice(0, 120)}`, ok: false },
|
|
4479
|
+
});
|
|
4480
|
+
tellThePerson();
|
|
4481
|
+
});
|
|
4482
|
+
}
|
|
4359
4483
|
function drainDelegationWakes() {
|
|
4360
4484
|
for (const [threadId, entry] of pendingDelegationWakes) {
|
|
4361
4485
|
if (!store.taskByThread(entry.botId, threadId) || !routineDelegationCanResume(threadId, entry.routineRunId)) {
|
|
@@ -4390,12 +4514,36 @@ function markTaskContextExternallyUpdated(bot, threadId) {
|
|
|
4390
4514
|
const task = store.taskByThread(bot.id, threadId);
|
|
4391
4515
|
if (!task)
|
|
4392
4516
|
return;
|
|
4517
|
+
// An engine that records which messages its current session was handed
|
|
4518
|
+
// keeps that session: its next turn is sent what it has not seen. Without a
|
|
4519
|
+
// record for that exact session (another engine, one switched in since, a
|
|
4520
|
+
// replaced session, or a task from before records existed) the next turn
|
|
4521
|
+
// replays once, as before.
|
|
4522
|
+
const owner = task.lastInstanceId;
|
|
4523
|
+
const record = owner ? task.handedMessages?.[owner] : undefined;
|
|
4524
|
+
if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
|
|
4525
|
+
turnInstance(botForThread(bot.id, threadId) ?? bot, undefined, threadId)?.instanceId === owner &&
|
|
4526
|
+
registry.get(owner)?.adapter.capabilities.strictResume) {
|
|
4527
|
+
store.patchTask(bot.id, threadId, { unread: true });
|
|
4528
|
+
return;
|
|
4529
|
+
}
|
|
4393
4530
|
store.patchTask(bot.id, threadId, {
|
|
4394
4531
|
resumeCursors: {},
|
|
4395
4532
|
lastInstanceId: `${EXTERNAL_CONTEXT_MARKER_PREFIX}${randomUUID()}`,
|
|
4396
4533
|
unread: true,
|
|
4397
4534
|
});
|
|
4398
4535
|
}
|
|
4536
|
+
/** Active-branch messages a provider reads as conversation context. */
|
|
4537
|
+
function isContextMessage(m) {
|
|
4538
|
+
return Boolean((m.kind === "text" && m.text) || m.roomRequest?.phase === "result");
|
|
4539
|
+
}
|
|
4540
|
+
const handoffs = new Handoffs({
|
|
4541
|
+
order: (threadId) => store.activePath(threadId).filter(isContextMessage).map((m) => m.id),
|
|
4542
|
+
read: (botId, threadId, instanceId) => store.taskByThread(botId, threadId)?.handedMessages?.[instanceId],
|
|
4543
|
+
write: (botId, threadId, instanceId, state) => store.setHandedMessages(botId, threadId, instanceId, state),
|
|
4544
|
+
replies: (threadId, turnId) => store.activePath(threadId)
|
|
4545
|
+
.filter((m) => m.role === "bot" && m.kind === "text" && !m.from && m.turnId === turnId).map((m) => m.id),
|
|
4546
|
+
});
|
|
4399
4547
|
/** Consume one delegated-turn watch and mirror exactly one terminal state.
|
|
4400
4548
|
* Some harness paths settle a busy bot without a provider turn.completed
|
|
4401
4549
|
* event, so they call this same finalizer explicitly. */
|
|
@@ -5095,15 +5243,18 @@ async function startTurn(botId, text, opts) {
|
|
|
5095
5243
|
// Resolve its quote from full storage, while the replay itself remains
|
|
5096
5244
|
// strictly limited to the selected branch below.
|
|
5097
5245
|
const messagesById = new Map(store.messagesFor(threadId).map((message) => [message.id, message]));
|
|
5098
|
-
const
|
|
5099
|
-
|
|
5100
|
-
.slice(-40)
|
|
5101
|
-
.map((m) => ({
|
|
5246
|
+
const context = activeMessages.filter(isContextMessage).map((m) => ({
|
|
5247
|
+
id: m.id,
|
|
5102
5248
|
role: m.role === "user" ? "user" : "assistant",
|
|
5103
5249
|
text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
|
|
5104
5250
|
: m.role !== "user" && m.from ? peerMessageText(m.from.name, transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"))
|
|
5105
5251
|
: transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"),
|
|
5252
|
+
keep: m.roomRequest?.phase === "result" || (m.role !== "user" && Boolean(m.from)),
|
|
5253
|
+
...(m.steered ? { steered: true } : {}),
|
|
5106
5254
|
}));
|
|
5255
|
+
const contextOrder = context.map((m) => m.id);
|
|
5256
|
+
const replayable = context.filter((m) => !skipTranscript.has(m.id));
|
|
5257
|
+
const transcript = replayable.slice(-40).map((m) => ({ role: m.role, text: m.text }));
|
|
5107
5258
|
// After a rewind (edit / branch switch) the provider's native session
|
|
5108
5259
|
// still contains the abandoned branch: start a fresh session instead of
|
|
5109
5260
|
// resuming, and for cursor-resuming drivers replay the surviving path
|
|
@@ -5122,6 +5273,28 @@ async function startTurn(botId, text, opts) {
|
|
|
5122
5273
|
const fresh = !rewound &&
|
|
5123
5274
|
!externalContextMarker &&
|
|
5124
5275
|
engineIsFresh({ instanceId, lastInstanceId: task.lastInstanceId, resumeCursors: task.resumeCursors, transcript });
|
|
5276
|
+
// An engine that records what its session was handed resumes it with only
|
|
5277
|
+
// the context messages outside that record. A record of another session
|
|
5278
|
+
// (the one it replaced) or one that no longer lines up with the branch is
|
|
5279
|
+
// not trusted: the session is rebuilt by the same replay as any other.
|
|
5280
|
+
const strictResume = instance.adapter.capabilities.strictResume === true;
|
|
5281
|
+
const cursor = task.resumeCursors[instanceId];
|
|
5282
|
+
const handed = strictResume && !rewound && !fresh && !externalContextMarker && cursor !== undefined
|
|
5283
|
+
? task.handedMessages?.[instanceId] : undefined;
|
|
5284
|
+
const unseen = handed && handedStateUsable(handed, cursor, contextOrder) ? unseenMessages(replayable, contextOrder, handed) : undefined;
|
|
5285
|
+
// A teammate's result or reply: without records this turn would replay.
|
|
5286
|
+
const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
|
|
5287
|
+
// What a resumed session keeps from its launch: the standing instructions
|
|
5288
|
+
// (tools, servers and — for Claude — the model are passed on every launch),
|
|
5289
|
+
// plus whatever this engine can only set when a session starts. Codex's
|
|
5290
|
+
// thread/resume sends no model selection, and an effort it is not sent stays
|
|
5291
|
+
// at the thread's last value, so both belong to the session there. An
|
|
5292
|
+
// external update that finds any of it changed since the session started
|
|
5293
|
+
// gets the fresh session and replay it always got, rather than a resume.
|
|
5294
|
+
const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
|
|
5295
|
+
...(instance.driverKind === "codex" ? [model, effort ?? null] : [])];
|
|
5296
|
+
const sessionConfig = (soul) => createHash("sha256").update(JSON.stringify([...persistentConfig, soul])).digest("hex").slice(0, 16);
|
|
5297
|
+
const plannedConfig = sessionConfig(bot.soul);
|
|
5125
5298
|
// Agent-tool gate shared by skill authoring, the /setup turn-text rewrite,
|
|
5126
5299
|
// the setup prompt block, and the peer-comms integration below: a driver
|
|
5127
5300
|
// that never mounts agent tools (or a turn already at the comms-depth cap)
|
|
@@ -5135,23 +5308,50 @@ async function startTurn(botId, text, opts) {
|
|
|
5135
5308
|
// which also depends on the bot's soul/description — is decided below,
|
|
5136
5309
|
// from the same bot snapshot the prompt's soul is built from.
|
|
5137
5310
|
const setupText = agentsMounted ? expandSetupTurnText(providerText) : providerText;
|
|
5138
|
-
const
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5311
|
+
const userTurnText = promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User");
|
|
5312
|
+
// Decided again at dispatch when setup outlasted a soul edit (config).
|
|
5313
|
+
const decideContext = (config) => {
|
|
5314
|
+
const handedStale = Boolean(handed && (!unseen || (externalUpdate && handed.config !== config)));
|
|
5315
|
+
const { block: unseenBlock, placed } = unseen && !handedStale ? renderUnseen(unseen) : { block: "", placed: [] };
|
|
5316
|
+
const { turnText: contextTurnText, resume } = buildTurnContext({
|
|
5317
|
+
text: userTurnText,
|
|
5318
|
+
transcript,
|
|
5319
|
+
rewound,
|
|
5320
|
+
fresh,
|
|
5321
|
+
externallyUpdated: Boolean(externalContextMarker) || handedStale,
|
|
5322
|
+
replaysNatively: instance.driverKind === "grok",
|
|
5323
|
+
});
|
|
5324
|
+
// Snapshot the cursor alongside the context decision. An external result
|
|
5325
|
+
// can arrive during async computer/setup work and clear the task cursor;
|
|
5326
|
+
// this already-built turn must either keep its old session or replay on the
|
|
5327
|
+
// following turn, never start a blank session with no transcript.
|
|
5328
|
+
const resumeCursor = resume ? task.resumeCursors[instanceId] : undefined;
|
|
5329
|
+
// A cursor the provider no longer honours must not brick the thread: the
|
|
5330
|
+
// driver may fall back to ONE fresh session, and this is what it sends
|
|
5331
|
+
// there, so the new session is not blank (server/resume-recovery.ts). A
|
|
5332
|
+
// turn carrying an external update gets the replay it would have had.
|
|
5333
|
+
const recoveryIsReplay = resumeCursor !== undefined && externalUpdate && transcript.length > 0;
|
|
5334
|
+
const recoveryText = resumeCursor === undefined ? undefined : recoveryIsReplay
|
|
5335
|
+
? buildTurnContext({ text: userTurnText, transcript, rewound: false, fresh: false, externallyUpdated: true, replaysNatively: false }).turnText
|
|
5336
|
+
: buildRecoveryText({ text: userTurnText, transcript });
|
|
5337
|
+
// What this turn puts in front of the provider, for each session it can end
|
|
5338
|
+
// up in (server/delta-context.ts). buildTurnContext prepends a replay only
|
|
5339
|
+
// when it replays, so a changed text means the transcript was sent.
|
|
5340
|
+
const carried = [...skipTranscript];
|
|
5341
|
+
const windowIds = replayable.slice(-40).map((m) => m.id);
|
|
5342
|
+
return {
|
|
5343
|
+
turnText: withUnseenMessages(unseenBlock, contextTurnText),
|
|
5344
|
+
resumeCursor, recoveryText, recoveryIsReplay,
|
|
5345
|
+
handoff: strictResume ? {
|
|
5346
|
+
botId: bot.id, instanceId, config, resumeCursor: typeof resumeCursor === "string" ? resumeCursor : undefined,
|
|
5347
|
+
started: sessionStart(contextOrder, contextTurnText !== userTurnText ? windowIds : [], carried),
|
|
5348
|
+
recovery: sessionStart(contextOrder, recoveryText !== undefined ? windowIds : [], carried),
|
|
5349
|
+
resumed: sessionStart(contextOrder, [], [...placed, ...carried]),
|
|
5350
|
+
placed, carried, own: [userMessage.id, ...(opts?.excludeMessageIds ?? [])],
|
|
5351
|
+
} : undefined,
|
|
5352
|
+
};
|
|
5353
|
+
};
|
|
5354
|
+
let dispatchContext = decideContext(plannedConfig);
|
|
5155
5355
|
const persona = [
|
|
5156
5356
|
`You are ${bot.name}, a personal bot in OpenMausBot.`,
|
|
5157
5357
|
bot.title && `Role: ${bot.title}.`,
|
|
@@ -5179,6 +5379,8 @@ async function startTurn(botId, text, opts) {
|
|
|
5179
5379
|
// follow-ups: any normal turn may ask teammates to coordinate work.
|
|
5180
5380
|
directFollowupSettlers.set(dispatchClaimId, { threadId, settle: opts?.onTurnSettled });
|
|
5181
5381
|
directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
|
|
5382
|
+
if (dispatchContext.handoff)
|
|
5383
|
+
handoffs.begin(threadId, dispatchClaimId, dispatchContext.handoff);
|
|
5182
5384
|
directTurnBots.set(threadId, bot);
|
|
5183
5385
|
beginInternalCapabilityGeneration(threadId, dispatchClaimId);
|
|
5184
5386
|
if (!opts?.computerSelectionContinuation && !opts?.cardContinuation && !opts?.automationSource && !opts?.unattended &&
|
|
@@ -5414,6 +5616,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5414
5616
|
autoVmClaims.set(threadId, {
|
|
5415
5617
|
owner: resourceOwner,
|
|
5416
5618
|
lazy: true,
|
|
5619
|
+
label: "the Local VM",
|
|
5417
5620
|
claim: async () => {
|
|
5418
5621
|
await claimAutoLocalVm(threadId, localVmTarget);
|
|
5419
5622
|
// The dispatch-site poller start saw a null previewCapture
|
|
@@ -5440,6 +5643,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5440
5643
|
// re-assert the same owner — a no-op (issue #1361).
|
|
5441
5644
|
autoVmClaims.set(threadId, {
|
|
5442
5645
|
owner: resourceOwner,
|
|
5646
|
+
label: "the Local VM",
|
|
5443
5647
|
claim: async () => { await claimAutoLocalVm(threadId); },
|
|
5444
5648
|
});
|
|
5445
5649
|
return true;
|
|
@@ -5484,10 +5688,17 @@ async function startTurn(botId, text, opts) {
|
|
|
5484
5688
|
if (unsupported && wants === undefined)
|
|
5485
5689
|
autoVpsProblem = unsupported;
|
|
5486
5690
|
if (!unsupported) {
|
|
5487
|
-
// The
|
|
5488
|
-
//
|
|
5489
|
-
|
|
5490
|
-
|
|
5691
|
+
// The VPS "computer" is the desktop inside this bot's managed
|
|
5692
|
+
// container, and only screen work needs that desktop to itself.
|
|
5693
|
+
// So the lease is claimed on the first computer call, through the
|
|
5694
|
+
// computer-control gate (the Local VM's seam, #1361), never at
|
|
5695
|
+
// mount: a bot's turns that never touch the computer tools run
|
|
5696
|
+
// side by side, and its 3-hourly routine no longer queues behind
|
|
5697
|
+
// — or fails after 30 minutes behind — its own long-running task.
|
|
5698
|
+
// Container lifecycle (provision, start) is serialized by the
|
|
5699
|
+
// runner's per-container lock, not by this turn.
|
|
5700
|
+
const vpsResource = `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`;
|
|
5701
|
+
vpsThreadStarted(bot.id, threadId);
|
|
5491
5702
|
let remote;
|
|
5492
5703
|
remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
|
|
5493
5704
|
? await vps.vpsComputerAction("provision", cfg, bot.id)
|
|
@@ -5501,10 +5712,28 @@ async function startTurn(botId, text, opts) {
|
|
|
5501
5712
|
env: { ...vpsMcp.env, OMB_CONTROL_URL: vpsControl.url, OMB_CONTROL_TOKEN: vpsControl.token },
|
|
5502
5713
|
};
|
|
5503
5714
|
computerKind = "vps";
|
|
5504
|
-
|
|
5715
|
+
// Live frames only once this turn holds the desktop: a poller on
|
|
5716
|
+
// a desktop another turn is driving would publish that turn's
|
|
5717
|
+
// screen as this one's. The claim restarts the poller with the
|
|
5718
|
+
// capture, the way the Local VM's lazy claim does.
|
|
5719
|
+
const vpsCapture = () => vps.vpsComputerScreenshot(targetCfg, bot.id);
|
|
5720
|
+
autoVmClaims.set(threadId, {
|
|
5721
|
+
owner: resourceOwner,
|
|
5722
|
+
lazy: true,
|
|
5723
|
+
label: "the VPS computer",
|
|
5724
|
+
claim: async () => {
|
|
5725
|
+
await bindTurnComputer(resourceOwner, vpsResource, true);
|
|
5726
|
+
previewCapture = vpsCapture;
|
|
5727
|
+
if (threadBusy(bot.id, threadId)) {
|
|
5728
|
+
const touched = screenPollers.get(threadId)?.touched ?? false;
|
|
5729
|
+
stopScreenPoller(bot.id, threadId);
|
|
5730
|
+
startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
|
|
5731
|
+
}
|
|
5732
|
+
},
|
|
5733
|
+
});
|
|
5505
5734
|
}
|
|
5506
5735
|
else {
|
|
5507
|
-
|
|
5736
|
+
vpsThreadEnded(bot.id, threadId);
|
|
5508
5737
|
if (wants === "cloud") {
|
|
5509
5738
|
throw new Error(remote?.problem ?? "the VPS computer could not be created or reached");
|
|
5510
5739
|
}
|
|
@@ -5786,10 +6015,17 @@ async function startTurn(botId, text, opts) {
|
|
|
5786
6015
|
{ id: "mentions", label: "Mentions", text: boundedCoordination && tagged.length ? `The user named these existing teammates: ${tagged.map(b => `${peerName(b.name)} (${b.id})`).join(", ")}. Use coordinate_bots when their contribution is needed; do not substitute native helper agents for these bots.` : mentionPrompt(tagged) },
|
|
5787
6016
|
]);
|
|
5788
6017
|
runningTurnEngines.set(threadId, instance);
|
|
6018
|
+
// The prompt carries the soul as saved now. If it changed during setup,
|
|
6019
|
+
// decide again from what is actually sent.
|
|
6020
|
+
const dispatchedConfig = sessionConfig(liveBot?.soul ?? bot.soul);
|
|
6021
|
+
if (strictResume && dispatchedConfig !== plannedConfig)
|
|
6022
|
+
dispatchContext = decideContext(dispatchedConfig);
|
|
6023
|
+
// Before sendTurn: an adapter may emit the whole turn before it resolves.
|
|
6024
|
+
handoffs.dispatching(threadId, dispatchClaimId, dispatchContext.handoff);
|
|
5789
6025
|
const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
|
|
5790
6026
|
threadId,
|
|
5791
6027
|
botId: bot.id,
|
|
5792
|
-
text: turnText,
|
|
6028
|
+
text: dispatchContext.turnText,
|
|
5793
6029
|
refreshSystemPrompt: true,
|
|
5794
6030
|
images: turnImages,
|
|
5795
6031
|
approvalMode: approvalModeForTurn(bot, commsDepth > 0),
|
|
@@ -5799,8 +6035,9 @@ async function startTurn(botId, text, opts) {
|
|
|
5799
6035
|
// a rewound thread never resumes the abandoned branch's session
|
|
5800
6036
|
// the active task's own session — another task's cursor would
|
|
5801
6037
|
// resume the wrong conversation and defeat the context bubble
|
|
5802
|
-
resumeCursor,
|
|
5803
|
-
...(recoveryText !== undefined ? { recoveryText } : {}),
|
|
6038
|
+
resumeCursor: dispatchContext.resumeCursor,
|
|
6039
|
+
...(dispatchContext.recoveryText !== undefined ? { recoveryText: dispatchContext.recoveryText } : {}),
|
|
6040
|
+
...(dispatchContext.recoveryIsReplay ? { recoveryIsReplay: true } : {}),
|
|
5804
6041
|
transcript,
|
|
5805
6042
|
system: prompt.text,
|
|
5806
6043
|
systemStable: prompt.stable,
|
|
@@ -5816,6 +6053,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5816
6053
|
throw new DirectTurnSetupCancelled("turn stopped during provider setup");
|
|
5817
6054
|
}
|
|
5818
6055
|
bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
|
|
6056
|
+
handoffs.bindTurn(threadId, dispatchClaimId, dispatch.value.turnId);
|
|
5819
6057
|
if (directFollowupSettlers.has(dispatchClaimId) && dispatch.value.turnId &&
|
|
5820
6058
|
!directFollowupTurns.bind(threadId, dispatchClaimId, dispatch.value.turnId)) {
|
|
5821
6059
|
// This exact queued turn completed before its dispatch ACK arrived.
|
|
@@ -5858,6 +6096,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5858
6096
|
}
|
|
5859
6097
|
}
|
|
5860
6098
|
catch (e) {
|
|
6099
|
+
handoffs.abandon(threadId, dispatchClaimId);
|
|
5861
6100
|
if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
|
|
5862
6101
|
computerSelectionTurns.delete(threadId);
|
|
5863
6102
|
settleDirectFollowup(dispatchClaimId);
|
|
@@ -5868,8 +6107,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5868
6107
|
releaseTurnResources(resourceOwner);
|
|
5869
6108
|
if (ownsLatestGeneration) {
|
|
5870
6109
|
releaseLocalVmThread(threadId);
|
|
5871
|
-
|
|
5872
|
-
activeVpsThreads.delete(bot.id);
|
|
6110
|
+
vpsThreadEnded(bot.id, threadId);
|
|
5873
6111
|
watchdog.settle(threadId);
|
|
5874
6112
|
turnUsage.delete(threadId);
|
|
5875
6113
|
turnContext.delete(threadId);
|
|
@@ -5910,6 +6148,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5910
6148
|
// for it, in its own thread, so it does not need a second channel.
|
|
5911
6149
|
if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
|
|
5912
6150
|
notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
|
|
6151
|
+
reportIncident({ kind: "could-not-start", bot, threadId, detail: message });
|
|
5913
6152
|
}
|
|
5914
6153
|
store.setTaskActivity(bot.id, threadId, "idle");
|
|
5915
6154
|
directTurnBots.delete(threadId);
|
|
@@ -6167,6 +6406,7 @@ routines = new RoutineManager({
|
|
|
6167
6406
|
const task = store.taskByThread(botId, threadId);
|
|
6168
6407
|
if (task && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
|
|
6169
6408
|
store.deleteTask(botId, threadId);
|
|
6409
|
+
handoffs.forget(threadId);
|
|
6170
6410
|
}
|
|
6171
6411
|
},
|
|
6172
6412
|
startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
|
|
@@ -6207,6 +6447,7 @@ routines = new RoutineManager({
|
|
|
6207
6447
|
const detail = run.error ? `${run.routineName}: ${run.error}` : run.routineName;
|
|
6208
6448
|
const notificationBot = routineSourceOwner(run)?.bot ?? bot;
|
|
6209
6449
|
notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
|
|
6450
|
+
reportIncident({ kind: "routine-failed", bot, threadId: run.threadId ?? bot.threadId, detail });
|
|
6210
6451
|
},
|
|
6211
6452
|
onRunDeferred: (run) => {
|
|
6212
6453
|
const bot = store.bot(run.botId);
|
|
@@ -9273,8 +9514,7 @@ async function reloadProviders() {
|
|
|
9273
9514
|
stopScreenPoller(botId, threadId);
|
|
9274
9515
|
releaseLocalVmThread(threadId);
|
|
9275
9516
|
releaseTurnResources(owner);
|
|
9276
|
-
|
|
9277
|
-
activeVpsThreads.delete(botId);
|
|
9517
|
+
vpsThreadEnded(botId, threadId);
|
|
9278
9518
|
watchdog.settle(threadId);
|
|
9279
9519
|
closeOpenApprovals(threadId);
|
|
9280
9520
|
directTurnBots.delete(threadId);
|
|
@@ -10791,6 +11031,52 @@ const handleRequest = async (req, res) => {
|
|
|
10791
11031
|
await new Promise((wake) => setTimeout(wake, 500));
|
|
10792
11032
|
}
|
|
10793
11033
|
}
|
|
11034
|
+
// A Chief resumes a teammate's broken thread (server/incidents.ts): the
|
|
11035
|
+
// same thread, its conversation and files, one more turn, with a line
|
|
11036
|
+
// saying who asked and why. Chief-only, for a teammate it can reach,
|
|
11037
|
+
// never a room (coordinate there) and never a thread still running.
|
|
11038
|
+
if (method === "POST" && path === "/api/internal/retry-thread") {
|
|
11039
|
+
const body = await readInternalBody();
|
|
11040
|
+
const from = internalSender;
|
|
11041
|
+
const fromThreadId = internalCapability.threadId;
|
|
11042
|
+
if (!from.chiefOfStaff || from.hidden)
|
|
11043
|
+
return json(res, 403, { error: "only a Chief of Staff can retry a teammate's thread" });
|
|
11044
|
+
// `toBotId`/`toThreadId`: the guard above reads bare botId/threadId as
|
|
11045
|
+
// the caller's own identity, the way every internal route does.
|
|
11046
|
+
const botId = typeof body.toBotId === "string" ? body.toBotId : "";
|
|
11047
|
+
const threadId = typeof body.toThreadId === "string" ? body.toThreadId : "";
|
|
11048
|
+
const note = typeof body.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
11049
|
+
const target = store.bot(botId);
|
|
11050
|
+
if (!target || target.id === from.id)
|
|
11051
|
+
return json(res, 404, { error: "no such teammate" });
|
|
11052
|
+
if (target.hidden || !canAccessTeam(from, target.section) || !peerAllowed(from, target.id)) {
|
|
11053
|
+
return json(res, 403, { error: "that bot is not on this Chief's team — call list_bots for the ones you can reach" });
|
|
11054
|
+
}
|
|
11055
|
+
if (store.groupByThread(threadId))
|
|
11056
|
+
return json(res, 400, { error: "that is a room thread — use coordinate_bots in the room instead" });
|
|
11057
|
+
const task = store.taskByThread(target.id, threadId);
|
|
11058
|
+
if (!task)
|
|
11059
|
+
return json(res, 404, { error: "no such thread on that bot" });
|
|
11060
|
+
if (threadBusy(target.id, threadId) || queuedThreadPosition(target.id, threadId) !== null) {
|
|
11061
|
+
return json(res, 409, { error: "that thread is still running — wait for it to settle before retrying" });
|
|
11062
|
+
}
|
|
11063
|
+
requireActiveInternalCapability();
|
|
11064
|
+
const unattended = isUnattended(from.id, fromThreadId);
|
|
11065
|
+
const text = `[Retry requested by ${from.name}, your Chief of Staff, after this thread's last run stopped.${note ? ` Note from ${from.name}: ${note}` : ""} Continue the request above from where it stopped and finish it. If the same problem comes back, say exactly what is blocking and stop.]`;
|
|
11066
|
+
try {
|
|
11067
|
+
await startTurn(target.id, text, { threadId, unattended, peerAsk: { botId: from.id, name: from.name, ...(unattended ? { unattended: true } : {}) } });
|
|
11068
|
+
}
|
|
11069
|
+
catch (error) {
|
|
11070
|
+
return json(res, 409, { error: error instanceof Error ? error.message : String(error) });
|
|
11071
|
+
}
|
|
11072
|
+
store.appendMessage(fromThreadId, {
|
|
11073
|
+
role: "bot",
|
|
11074
|
+
kind: "activity",
|
|
11075
|
+
tool: { name: `Retried ${target.name}'s thread #${task.title}`, ok: true },
|
|
11076
|
+
threadRef: { botId: target.id, threadId, title: task.title },
|
|
11077
|
+
});
|
|
11078
|
+
return json(res, 200, { started: true, message: `${target.name}'s thread #${task.title} is running again. Its result stays in that thread; you are not woken for it — check later with list_threads or session_search if you need to.` });
|
|
11079
|
+
}
|
|
10794
11080
|
if (method === "POST" && path === "/api/internal/delegate-bot") {
|
|
10795
11081
|
const body = await readInternalBody();
|
|
10796
11082
|
const toBotRef = String(body.toBotId ?? "");
|
|
@@ -11492,7 +11778,7 @@ const handleRequest = async (req, res) => {
|
|
|
11492
11778
|
// model into a screenshot loop against a claim that cannot land.
|
|
11493
11779
|
return json(res, 200, {
|
|
11494
11780
|
held: true, helpOpen: false,
|
|
11495
|
-
blockedReason: `This turn could not claim
|
|
11781
|
+
blockedReason: `This turn could not claim ${lazyClaim.label ?? "this computer"}${lazyClaim.failure ? ` (${lazyClaim.failure})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`,
|
|
11496
11782
|
});
|
|
11497
11783
|
}
|
|
11498
11784
|
if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
|
|
@@ -14403,6 +14689,7 @@ const handleRequest = async (req, res) => {
|
|
|
14403
14689
|
// message intact for the next ordinary turn, where central image
|
|
14404
14690
|
// admission can hand it to the provider natively.
|
|
14405
14691
|
const carriesImages = extractTurnImages(text).images.length > 0;
|
|
14692
|
+
const steerTarget = handoffs.current(threadId);
|
|
14406
14693
|
if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14407
14694
|
steered = await instance.adapter
|
|
14408
14695
|
.steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
|
|
@@ -14447,6 +14734,8 @@ const handleRequest = async (req, res) => {
|
|
|
14447
14734
|
sendId,
|
|
14448
14735
|
steered: true,
|
|
14449
14736
|
});
|
|
14737
|
+
// Offered to the next turn again unless the person stops this one.
|
|
14738
|
+
handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
|
|
14450
14739
|
return { ok: true, steered: true, threadId, message };
|
|
14451
14740
|
}
|
|
14452
14741
|
if (!current.busy) {
|
|
@@ -14497,6 +14786,7 @@ const handleRequest = async (req, res) => {
|
|
|
14497
14786
|
const currentAtStart = store.projectBotForTask(bot.id, bot.threadId);
|
|
14498
14787
|
const instance = currentAtStart?.busy ? runningTurnInstance(currentAtStart, bot.threadId) : undefined;
|
|
14499
14788
|
const prompt = held.items.map((item) => item.prompt).join("\n\n");
|
|
14789
|
+
const steerTarget = handoffs.current(bot.threadId);
|
|
14500
14790
|
let steered = "refused";
|
|
14501
14791
|
if (currentAtStart?.busy && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14502
14792
|
steered = await instance.adapter
|
|
@@ -14525,6 +14815,9 @@ const handleRequest = async (req, res) => {
|
|
|
14525
14815
|
peerAsk: item.peerAsk,
|
|
14526
14816
|
steered: true,
|
|
14527
14817
|
}));
|
|
14818
|
+
// Offered to the next turn again unless the person stops this one.
|
|
14819
|
+
for (const message of messages)
|
|
14820
|
+
handoffs.steered(bot.threadId, steerTarget, instance?.instanceId, message.id);
|
|
14528
14821
|
const queueIds = held.items.map((item) => item.messageId);
|
|
14529
14822
|
settleHeldSteeredQueue(held);
|
|
14530
14823
|
return json(res, 200, { ok: true, steered: true, threadId: bot.threadId, messages, queueIds });
|
|
@@ -14761,8 +15054,10 @@ const handleRequest = async (req, res) => {
|
|
|
14761
15054
|
const routine = routines.activeBotRunForBot(bot.id);
|
|
14762
15055
|
if (routine?.threadId === expectedThreadId)
|
|
14763
15056
|
await routines.cancelRun(routine.id);
|
|
14764
|
-
else
|
|
15057
|
+
else {
|
|
15058
|
+
handoffs.stoppedByPerson(expectedThreadId);
|
|
14765
15059
|
await interruptDirectThread(bot.id, expectedThreadId);
|
|
15060
|
+
}
|
|
14766
15061
|
return json(res, 200, { ok: true });
|
|
14767
15062
|
}
|
|
14768
15063
|
const directClaim = directTurnDispatchClaims.get(bot.threadId);
|
|
@@ -14798,6 +15093,7 @@ const handleRequest = async (req, res) => {
|
|
|
14798
15093
|
directClaim?.threadId !== expectedThreadId) {
|
|
14799
15094
|
return json(res, 409, { error: "the bot switched tasks before it could be interrupted" });
|
|
14800
15095
|
}
|
|
15096
|
+
handoffs.stoppedByPerson(expectedThreadId ?? bot.threadId);
|
|
14801
15097
|
await interruptDirectThread(bot.id, expectedThreadId ?? bot.threadId);
|
|
14802
15098
|
return json(res, 200, { ok: true });
|
|
14803
15099
|
}
|
|
@@ -15030,6 +15326,7 @@ const handleRequest = async (req, res) => {
|
|
|
15030
15326
|
const updated = store.deleteTask(m[1], m[2]);
|
|
15031
15327
|
if (!updated)
|
|
15032
15328
|
return json(res, 404, { error: "no such task" });
|
|
15329
|
+
handoffs.forget(m[2]);
|
|
15033
15330
|
settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
|
|
15034
15331
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
15035
15332
|
const fresh = botWithThread(updated);
|
|
@@ -16929,12 +17226,17 @@ for (const row of chatFollowups()) {
|
|
|
16929
17226
|
}
|
|
16930
17227
|
settleChatFollowups([row.id], "interrupted");
|
|
16931
17228
|
const messages = store.messagesFor(row.threadId);
|
|
16932
|
-
|
|
16933
|
-
|
|
16934
|
-
|
|
16935
|
-
|
|
16936
|
-
|
|
16937
|
-
|
|
17229
|
+
const recovered = messages.find((message) => message.queueId === row.id && message.role === "user") ?? store.appendMessage(row.threadId, {
|
|
17230
|
+
role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
|
|
17231
|
+
sendId: row.payload.sendId, queueId: row.id,
|
|
17232
|
+
...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
|
|
17233
|
+
});
|
|
17234
|
+
// Nor as a message a resumed session has not seen: count it as handed.
|
|
17235
|
+
const recoveredTask = row.kind === "bot" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
|
|
17236
|
+
const order = store.activePath(row.threadId).filter(isContextMessage).map((m) => m.id);
|
|
17237
|
+
for (const [instanceId, state] of Object.entries(recoveredTask?.handedMessages ?? {})) {
|
|
17238
|
+
if (state.session !== undefined)
|
|
17239
|
+
store.setHandedMessages(row.ownerId, row.threadId, instanceId, recordHanded(state, order, [recovered.id]));
|
|
16938
17240
|
}
|
|
16939
17241
|
if (!messages.some((message) => message.queueId === row.id && message.kind === "activity")) {
|
|
16940
17242
|
store.appendMessage(row.threadId, {
|