openmausbot 0.1.81 → 0.1.83
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-peLFiSH8.js → index-DSvqfBOf.js} +151 -149
- package/dist/assets/{index-oLMljotU.js → index-nqTFZTxg.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/container-mcp.js +20 -4
- package/dist-server/drivers/agents-proxy.js +4 -4
- package/dist-server/index.js +1155 -511
- package/dist-server/mcp-gate.js +2 -1
- package/dist-server/openmausbot.js +210 -56
- package/dist-server/pair-cli.js +210 -56
- package/dist-server/server/auto-approve.js +15 -1
- package/dist-server/server/auto-vm-claims.js +22 -0
- package/dist-server/server/browser-bundle-release.js +8 -7
- package/dist-server/server/browser-engine.js +1 -1
- package/dist-server/server/channel-queue.js +58 -0
- package/dist-server/server/chief-of-staff.js +2 -2
- package/dist-server/server/config.js +33 -2
- package/dist-server/server/contracts.js +0 -7
- package/dist-server/server/drivers/acp/core.js +1 -1
- package/dist-server/server/drivers/acp/gemini.js +11 -1
- package/dist-server/server/drivers/acp/opencode-go.js +13 -0
- package/dist-server/server/drivers/acp/qwen.js +16 -1
- package/dist-server/server/drivers/agents-proxy.js +4 -4
- package/dist-server/server/drivers/claude.js +4 -3
- package/dist-server/server/drivers/codex.js +66 -1
- package/dist-server/server/drivers/pi.js +2 -1
- package/dist-server/server/env-path.js +6 -1
- package/dist-server/server/index.js +596 -97
- package/dist-server/server/local-routing.js +10 -4
- package/dist-server/server/notify.js +8 -3
- package/dist-server/server/peer-roster.js +37 -1
- package/dist-server/server/recent-work.js +4 -1
- package/dist-server/server/redact.js +5 -48
- package/dist-server/server/room-handoffs.js +79 -16
- package/dist-server/server/routines.js +19 -1
- package/dist-server/server/skill-fetch.js +1 -1
- package/dist-server/server/steer-queue.js +29 -0
- package/dist-server/server/store.js +26 -3
- package/dist-server/server/thread-retention.js +50 -0
- package/dist-server/server/turn-context.js +7 -0
- package/dist-server/server/turn-resources.js +13 -2
- package/dist-server/shared/approval-mode.js +8 -5
- package/dist-server/shared/inspector.js +1 -0
- package/dist-server/shared/json.js +1 -0
- package/dist-server/shared/notification.js +1 -0
- package/dist-server/shared/redact.js +61 -0
- package/dist-server/shared/routines.js +1 -0
- package/dist-server/shared/runtime-events.js +1 -0
- package/dist-server/shared/webhooks.js +1 -0
- package/dist-server/shared/wire.js +8 -0
- package/dist-server/vps-container-mcp.js +20 -4
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (upstream rule): the React app dispatches typed commands over HTTP and
|
|
3
3
|
// folds one SSE event stream; every provider process runs here.
|
|
4
4
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
-
import { existsSync, readFileSync, rmSync
|
|
5
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
6
6
|
import { rm as removeDirectory } from "node:fs/promises";
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
8
|
import { extname, join } from "node:path";
|
|
@@ -16,7 +16,7 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
|
|
|
16
16
|
import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
|
|
17
17
|
import { escapeAttribute } from "../src/lib/composer-attachments.js";
|
|
18
18
|
import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
|
|
19
|
-
import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval } from "./auto-approve.js";
|
|
19
|
+
import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
|
|
20
20
|
import { updateClaudeCli } from "./claude-update.js";
|
|
21
21
|
import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
|
|
22
22
|
import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
|
|
@@ -31,15 +31,17 @@ import { groupTurnCwd } from "./room-cwd.js";
|
|
|
31
31
|
import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
|
|
32
32
|
import * as box from "./box.js";
|
|
33
33
|
import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
|
|
34
|
+
import { isEffortLevel } from "../shared/wire.js";
|
|
34
35
|
import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-idempotency.js";
|
|
35
36
|
import { boxDeletionSnapshot } from "./box-delete-journal.js";
|
|
36
37
|
import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
|
|
37
38
|
import * as composio from "./composio.js";
|
|
38
39
|
import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
|
|
39
|
-
import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
|
|
40
|
+
import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
|
|
40
41
|
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
41
42
|
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
|
|
42
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads,
|
|
43
|
+
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, } from "./config.js";
|
|
44
|
+
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
43
45
|
import { ComputerControl } from "./computer-control.js";
|
|
44
46
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
45
47
|
import { registerEnginesBinDir } from "./engine-install.js";
|
|
@@ -51,7 +53,7 @@ import { entitled } from "./enterprise.js";
|
|
|
51
53
|
import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
|
|
52
54
|
import { describeSpawnFailure, execCli } from "./procs.js";
|
|
53
55
|
import { blockedTarget, buildNotification } from "./notify.js";
|
|
54
|
-
import {
|
|
56
|
+
import { isModelVariant, newId, } from "./contracts.js";
|
|
55
57
|
import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
|
|
56
58
|
import { decodeGeneratedImage } from "./generated-image.js";
|
|
57
59
|
import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
@@ -67,8 +69,8 @@ import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./rec
|
|
|
67
69
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
68
70
|
import { promptWithReply, transcriptText } from "./replies.js";
|
|
69
71
|
import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
|
|
70
|
-
import { cancelSteeredMessage, drainSteeredMessages, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreSteeredMessages, } from "./steer-queue.js";
|
|
71
|
-
import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, } from "./channel-queue.js";
|
|
72
|
+
import { cancelSteeredMessage, drainSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
|
|
73
|
+
import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
|
|
72
74
|
import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
|
|
73
75
|
import { EventBus } from "./harness/bus.js";
|
|
74
76
|
import { ProviderRegistry } from "./harness/registry.js";
|
|
@@ -77,10 +79,10 @@ import { selectDefaultModelSelection } from "./default-model-selection.js";
|
|
|
77
79
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
78
80
|
import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
|
|
79
81
|
import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
|
|
80
|
-
import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, } from "./store.js";
|
|
82
|
+
import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, toWireTask, } from "./store.js";
|
|
81
83
|
import * as tts from "./tts/index.js";
|
|
82
84
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
83
|
-
import { buildRecoveryText, buildTurnContext, engineIsFresh } from "./turn-context.js";
|
|
85
|
+
import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
|
|
84
86
|
import { extractTurnImages } from "./turn-images.js";
|
|
85
87
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
86
88
|
import { TurnResources, workspaceResource } from "./turn-resources.js";
|
|
@@ -135,6 +137,7 @@ import { createTeamBackup, importTeamBackup } from "./team-backup.js";
|
|
|
135
137
|
import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
|
|
136
138
|
import { shouldMountLocalComputer } from "./local-routing.js";
|
|
137
139
|
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
140
|
+
import { startAutoVmClaim } from "./auto-vm-claims.js";
|
|
138
141
|
import { modelContextWindow } from "./model-context-window.js";
|
|
139
142
|
import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
140
143
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
@@ -603,6 +606,8 @@ function claimTurnResource(owner, resource) {
|
|
|
603
606
|
function releaseTurnResources(owner) {
|
|
604
607
|
if (!owner)
|
|
605
608
|
return;
|
|
609
|
+
if (autoVmClaims.get(owner.threadId)?.owner.generation === owner.generation)
|
|
610
|
+
autoVmClaims.delete(owner.threadId);
|
|
606
611
|
if (settlingResourceOwners.get(owner.threadId) === owner.generation)
|
|
607
612
|
settlingResourceOwners.delete(owner.threadId);
|
|
608
613
|
turnResources.release(owner);
|
|
@@ -616,9 +621,39 @@ function releaseTurnResources(owner) {
|
|
|
616
621
|
teamComputerTurns.delete(owner.threadId);
|
|
617
622
|
}
|
|
618
623
|
}
|
|
619
|
-
function bindTurnComputer(owner, resource, exclusive = false) {
|
|
620
|
-
|
|
621
|
-
|
|
624
|
+
async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
625
|
+
const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
|
|
626
|
+
turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
|
|
627
|
+
let waitingMessage;
|
|
628
|
+
const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
|
|
629
|
+
try {
|
|
630
|
+
while (true) {
|
|
631
|
+
if (!active())
|
|
632
|
+
throw new DirectTurnSetupCancelled("Computer wait cancelled");
|
|
633
|
+
if (!exclusive || claimTurnResource(owner, resource))
|
|
634
|
+
break;
|
|
635
|
+
if (!waitingMessage) {
|
|
636
|
+
const blocker = turnResources.blocker(resource, owner);
|
|
637
|
+
const holderBot = blocker && store.botByThread(blocker.threadId);
|
|
638
|
+
const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
|
|
639
|
+
const holder = holderBot ? `${holderBot.name}${holderTask?.title ? ` / ${holderTask.title}` : ""}`
|
|
640
|
+
: blocker && store.groupByThread(blocker.threadId)?.name;
|
|
641
|
+
waitingMessage = store.appendMessage(owner.threadId, {
|
|
642
|
+
role: "bot", kind: "activity",
|
|
643
|
+
tool: { name: `Waiting for computer${holder ? ` — ${holder} is using it` : ""}; will continue automatically` },
|
|
644
|
+
...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
if (Date.now() >= deadline)
|
|
648
|
+
throw new Error("Computer is still busy. Stop the turn using it, then retry.");
|
|
649
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
if (waitingMessage)
|
|
654
|
+
store.patchMessage(owner.threadId, waitingMessage.id, {
|
|
655
|
+
tool: { name: active() && turnResources.owns(resource, owner) ? "Computer available — continuing" : "Computer wait ended", ok: true },
|
|
656
|
+
});
|
|
622
657
|
}
|
|
623
658
|
turnResourceOwners.set(owner.threadId, owner);
|
|
624
659
|
turnComputerResources.set(owner.threadId, { owner, resource });
|
|
@@ -646,6 +681,14 @@ function botAtThreadCapacity(botId) {
|
|
|
646
681
|
function hasDirectDispatch(botId) {
|
|
647
682
|
return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
|
|
648
683
|
}
|
|
684
|
+
/** Routine and webhook dispatch shares startTurn's admission preconditions
|
|
685
|
+
* instead of waiting for whole-bot idleness: a free thread slot and no
|
|
686
|
+
* active group turn. A group turn blocks scheduled starts the same way it
|
|
687
|
+
* blocks every other turn kind; it does not consume a capacity slot. */
|
|
688
|
+
function unattendedDispatchState(botId) {
|
|
689
|
+
const bot = store.bot(botId);
|
|
690
|
+
return !bot ? "missing" : botAtThreadCapacity(botId) || activeGroupTurnForBot(botId) ? "busy" : "ready";
|
|
691
|
+
}
|
|
649
692
|
function requestedTaskBot(botId, rawThreadId) {
|
|
650
693
|
const profile = store.bot(botId);
|
|
651
694
|
if (!profile)
|
|
@@ -1235,7 +1278,9 @@ if (browserCleanupReferencesReconciled)
|
|
|
1235
1278
|
* than the desktop window did. Stripped here rather than at each call site
|
|
1236
1279
|
* so a new broadcast cannot forget. */
|
|
1237
1280
|
let activeCoordinationForThread = (_threadId) => false;
|
|
1238
|
-
const wireTask = (
|
|
1281
|
+
const wireTask = (task) => activeCoordinationForThread(task.threadId) && !task.busy
|
|
1282
|
+
? { ...toWireTask(task), busy: true, activity: "working" }
|
|
1283
|
+
: toWireTask(task);
|
|
1239
1284
|
const wireBot = (bot) => {
|
|
1240
1285
|
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
|
|
1241
1286
|
// An elevated selection is inert until the desktop confirms its exact
|
|
@@ -1403,7 +1448,10 @@ async function botOverview(bot) {
|
|
|
1403
1448
|
* approval semantics require an implemented provider mapping. The trusted transition enforces
|
|
1404
1449
|
* this too, but no provider dispatch or later permission callback relies on
|
|
1405
1450
|
* persistence having been produced exclusively by that route. Delegation
|
|
1406
|
-
* uses the receiving bot's grant, never the sender's
|
|
1451
|
+
* uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
|
|
1452
|
+
* with one deliberate exception: a Chief of Staff with Full access makes the
|
|
1453
|
+
* threads it delegates Full too (delegatedFullAccess), so the grant the
|
|
1454
|
+
* person gave the Chief covers the work the Chief hands out. */
|
|
1407
1455
|
const approvalModeForTurn = (bot, peerInitiated = false) => {
|
|
1408
1456
|
const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
|
|
1409
1457
|
if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
|
|
@@ -1424,6 +1472,48 @@ function fullAccessForSource(botId, threadId) {
|
|
|
1424
1472
|
function peerReviewRequired(bot, threadId) {
|
|
1425
1473
|
return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
|
|
1426
1474
|
}
|
|
1475
|
+
/** Full access flows down a Chief of Staff's delegation. The person gave the
|
|
1476
|
+
* Chief Full access so its work runs without prompts; a teammate stopping
|
|
1477
|
+
* that same work to ask defeats the grant — and in practice the person was
|
|
1478
|
+
* answering every one of those cards, all day, for the whole team. So a
|
|
1479
|
+
* teammate a Full-access Chief delegates to runs Full for that work: the
|
|
1480
|
+
* recipient switches, whatever its own level says. The recipient's engine
|
|
1481
|
+
* has to implement Full (supportsApprovalMode); otherwise the work keeps the
|
|
1482
|
+
* recipient's own level, as before. Only a Chief passes access on — an
|
|
1483
|
+
* ordinary bot's delegation still uses the recipient's setting. */
|
|
1484
|
+
function delegatedFullAccess(from, fromThreadId, target) {
|
|
1485
|
+
return delegationInheritsFullAccess({
|
|
1486
|
+
senderIsChief: Boolean(from.chiefOfStaff),
|
|
1487
|
+
senderHasFullAccess: fullAccessForSource(from.id, fromThreadId),
|
|
1488
|
+
sameBot: from.id === target.id,
|
|
1489
|
+
recipientDriverKind: registry.cliTarget(target.modelSelection.instanceId)?.driverKind,
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
/** Make a delegated thread Full and say so in it once, so the level the
|
|
1493
|
+
* chip shows and the level the turns run at agree, and the person can see
|
|
1494
|
+
* where the access came from. Idempotent: a pair conversation is reused
|
|
1495
|
+
* across delegations and must not collect a chip per request. */
|
|
1496
|
+
function grantDelegatedFullAccess(from, target, threadId) {
|
|
1497
|
+
if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
|
|
1498
|
+
return;
|
|
1499
|
+
store.patchTask(target.id, threadId, { approvalMode: "full", autoApprove: false, alwaysAllow: [] });
|
|
1500
|
+
store.appendMessage(threadId, {
|
|
1501
|
+
role: "bot",
|
|
1502
|
+
kind: "activity",
|
|
1503
|
+
tool: { name: `Full access — delegated by ${from.name}, a Chief of Staff with Full access`, ok: true },
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
/** A room member's level for one turn. Work a Full-access Chief hands out
|
|
1507
|
+
* in a room runs Full for that turn: the room thread is shared, so the
|
|
1508
|
+
* level is not stored on it — it rides the handoff. */
|
|
1509
|
+
function roomTurnApprovalMode(bot, orchestration) {
|
|
1510
|
+
const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
|
|
1511
|
+
const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
|
|
1512
|
+
const from = source ? store.bot(source.botId) : undefined;
|
|
1513
|
+
if (from && source && delegatedFullAccess(from, source.threadId, bot))
|
|
1514
|
+
return "full";
|
|
1515
|
+
return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId));
|
|
1516
|
+
}
|
|
1427
1517
|
/** Privileged approval-mode transitions are deliberately absent from the
|
|
1428
1518
|
* loopback HTTP authority model: a bot with shell access can curl that
|
|
1429
1519
|
* surface itself. Only Electron's private utility-process channel can deliver
|
|
@@ -2230,7 +2320,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
2230
2320
|
await tracked;
|
|
2231
2321
|
return { ok: result.outcome === "settled", text: result.stopReason || result.replyText || result.outcome || "The addressed agent could not run" };
|
|
2232
2322
|
},
|
|
2233
|
-
});
|
|
2323
|
+
}, Date.now, roomHandoffLimits(cfg));
|
|
2234
2324
|
activeCoordinationForThread = threadId => roomHandoffs.activeDirect(threadId);
|
|
2235
2325
|
function publicGroupState(group) {
|
|
2236
2326
|
return { ...group, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
|
|
@@ -2943,10 +3033,12 @@ const watchdog = new TurnWatchdog({
|
|
|
2943
3033
|
groupSpeakers.delete(turn.threadId);
|
|
2944
3034
|
store.patchGroup(group.id, { busyBotId: null, unread: true });
|
|
2945
3035
|
}
|
|
3036
|
+
// A cleared UI busy flag must not strand this finished generation's
|
|
3037
|
+
// computer claim. The generation checks above protect replacements.
|
|
3038
|
+
releaseTurnResources(stalledResourceOwner);
|
|
2946
3039
|
const currentBot = store.bot(turn.botId);
|
|
2947
3040
|
if (currentBot?.busy) {
|
|
2948
3041
|
stopScreenPoller(currentBot.id, turn.threadId);
|
|
2949
|
-
releaseTurnResources(stalledResourceOwner);
|
|
2950
3042
|
if (activeVpsThreads.get(currentBot.id) === turn.threadId)
|
|
2951
3043
|
activeVpsThreads.delete(currentBot.id);
|
|
2952
3044
|
if (store.taskByThread(currentBot.id, turn.threadId))
|
|
@@ -3120,6 +3212,15 @@ const localVmLeases = new LocalVmLeasePool(30 * 60_000);
|
|
|
3120
3212
|
const localVmLifecycleBusy = new Set();
|
|
3121
3213
|
const localVmThreadTargets = new Map();
|
|
3122
3214
|
const localVmActiveThreads = new Map();
|
|
3215
|
+
// Lazy Auto-VM claims (issue #1361): a thread whose auto-resolved Local VM
|
|
3216
|
+
// attach deferred the exclusive claim registers here so the first screen
|
|
3217
|
+
// tools/call gate can fire it. Dispatch claims eagerly today, so entries
|
|
3218
|
+
// exist only as a no-op handoff to the gate.
|
|
3219
|
+
const autoVmClaims = new Map();
|
|
3220
|
+
/** How long the computer-control gate lets a lazy claim land before it
|
|
3221
|
+
* answers held. A free, ready VM claims in the time of one container
|
|
3222
|
+
* inspect; only a claim queued behind another holder outlives this. */
|
|
3223
|
+
const LAZY_VM_CLAIM_GRACE_MS = 5_000;
|
|
3123
3224
|
/** Local VM targets this process has seen ready or recreatable — at boot, in
|
|
3124
3225
|
* the inventory, or in a turn. Auto probes the container runtime for a VM
|
|
3125
3226
|
* only when one of these exists, so an ordinary Auto turn on a machine with
|
|
@@ -3223,12 +3324,12 @@ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
|
|
|
3223
3324
|
throw new Error("The team computer is being changed; wait for it to finish");
|
|
3224
3325
|
if (computerControl.snapshot(ownerId).held)
|
|
3225
3326
|
throw new Error("Release human control of the team computer before starting another turn");
|
|
3226
|
-
bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
|
|
3327
|
+
await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
|
|
3227
3328
|
teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
|
|
3228
3329
|
let machine = await box.findBox(cfg, ownerId);
|
|
3229
3330
|
if (!machine)
|
|
3230
3331
|
throw new Error("The team's Box computer is missing; explicitly create or retry it from the Team map");
|
|
3231
|
-
bindTurnComputer(owner, `computer:box:${machine.id}`, true);
|
|
3332
|
+
await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
|
|
3232
3333
|
const action = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
|
|
3233
3334
|
if (action === "wake")
|
|
3234
3335
|
machine = await box.readyBox(cfg, ownerId);
|
|
@@ -3545,6 +3646,10 @@ function localVmIdleFor(target) {
|
|
|
3545
3646
|
return idle;
|
|
3546
3647
|
}
|
|
3547
3648
|
function releaseLocalVmThread(threadId) {
|
|
3649
|
+
// Covers lazy claims too (issue #1361): every settle path funnels through
|
|
3650
|
+
// here or through releaseTurnResources, so a turn that ends before its
|
|
3651
|
+
// first screen call leaves no claim slot behind.
|
|
3652
|
+
autoVmClaims.delete(threadId);
|
|
3548
3653
|
const target = localVmThreadTargets.get(threadId);
|
|
3549
3654
|
if (!target)
|
|
3550
3655
|
return;
|
|
@@ -4975,6 +5080,17 @@ async function startTurn(botId, text, opts) {
|
|
|
4975
5080
|
// branch only — abandoned forks never reach the model
|
|
4976
5081
|
const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]);
|
|
4977
5082
|
const activeMessages = store.activePath(threadId);
|
|
5083
|
+
// This turn's own text carries the addressed request and, when resuming,
|
|
5084
|
+
// every child result as JSON: neither is repeated from the transcript.
|
|
5085
|
+
const coordinationChildren = opts?.coordination?.resumed
|
|
5086
|
+
? new Set(roomHandoffs.children(opts.coordination.id).map((child) => child.id)) : undefined;
|
|
5087
|
+
for (const m of activeMessages) {
|
|
5088
|
+
if (!opts?.coordination || !m.roomRequest)
|
|
5089
|
+
continue;
|
|
5090
|
+
if ((m.roomRequest.phase === "request" && m.roomRequest.id === opts.coordination.id) ||
|
|
5091
|
+
(m.roomRequest.phase === "result" && coordinationChildren?.has(m.roomRequest.id)))
|
|
5092
|
+
skipTranscript.add(m.id);
|
|
5093
|
+
}
|
|
4978
5094
|
// A flat reply may deliberately point across a fork in the same thread.
|
|
4979
5095
|
// Resolve its quote from full storage, while the replay itself remains
|
|
4980
5096
|
// strictly limited to the selected branch below.
|
|
@@ -4985,7 +5101,8 @@ async function startTurn(botId, text, opts) {
|
|
|
4985
5101
|
.map((m) => ({
|
|
4986
5102
|
role: m.role === "user" ? "user" : "assistant",
|
|
4987
5103
|
text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
|
|
4988
|
-
: transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User")
|
|
5104
|
+
: m.role !== "user" && m.from ? peerMessageText(m.from.name, transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"))
|
|
5105
|
+
: transcriptText(m, messagesById, cfg.profile?.name?.trim() || "User"),
|
|
4989
5106
|
}));
|
|
4990
5107
|
// After a rewind (edit / branch switch) the provider's native session
|
|
4991
5108
|
// still contains the abandoned branch: start a fresh session instead of
|
|
@@ -5183,6 +5300,79 @@ async function startTurn(botId, text, opts) {
|
|
|
5183
5300
|
let browserCapture = null;
|
|
5184
5301
|
let computerKind = null;
|
|
5185
5302
|
let autoVpsProblem = null;
|
|
5303
|
+
/** The Local VM frame capture for the poller and the settled transcript
|
|
5304
|
+
* screenshot. The shared desktop outlives the turn: once another thread
|
|
5305
|
+
* owns it, a capture still in flight would picture ITS work under this
|
|
5306
|
+
* bot's name — live and in the settled frame, which is taken after the
|
|
5307
|
+
* lease is already released. No owner means the desktop is simply
|
|
5308
|
+
* idle: that final frame is ours to keep. */
|
|
5309
|
+
const localVmPreviewFor = (localVmTarget, claimThreadId) => () => {
|
|
5310
|
+
const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
|
|
5311
|
+
if (owner && owner.threadId !== claimThreadId) {
|
|
5312
|
+
throw new Error("the Local VM moved on to another turn");
|
|
5313
|
+
}
|
|
5314
|
+
return containerComputerFrame(undefined, undefined, localVmTarget);
|
|
5315
|
+
};
|
|
5316
|
+
/** The exclusive Local VM claim sequence, verbatim from the old inline
|
|
5317
|
+
* attach path, shared by dispatch (eager) and the first-screen-call
|
|
5318
|
+
* gate (issue #1361). Idempotent per turn: the resource claim and the
|
|
5319
|
+
* lease both re-assert the same owner, so a re-entrant call from the
|
|
5320
|
+
* gate no-ops once dispatch has already claimed. A lazy attach pins
|
|
5321
|
+
* the target it mounted the tools against: the claim must lease that
|
|
5322
|
+
* desktop, not whatever localVmTargetForBot resolves to by the time
|
|
5323
|
+
* the first screen call arrives. */
|
|
5324
|
+
const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
|
|
5325
|
+
const localVmTarget = pinnedTarget ?? localVmTargetForBot(bot.id);
|
|
5326
|
+
await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
|
|
5327
|
+
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
|
|
5328
|
+
throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
|
|
5329
|
+
}
|
|
5330
|
+
// Claim before the first await. The lifecycle route performs its
|
|
5331
|
+
// matching check synchronously, so neither side can enter while the
|
|
5332
|
+
// other is between inspection and mutation.
|
|
5333
|
+
if (!localVmLeaseFor(localVmTarget).claim(claimThreadId, bot.id, localVmOwnerBusy)) {
|
|
5334
|
+
throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
|
|
5335
|
+
}
|
|
5336
|
+
localVmThreadTargets.set(claimThreadId, localVmTarget);
|
|
5337
|
+
localVmActiveThreads.set(localVmTarget.key, claimThreadId);
|
|
5338
|
+
localVmIdleFor(localVmTarget).touch();
|
|
5339
|
+
// The lease is held from here. An eager attach that fails below
|
|
5340
|
+
// fails the turn and settle releases it; a lazy claim's rejection is
|
|
5341
|
+
// swallowed into the slot's failed flag and the turn carries on, so
|
|
5342
|
+
// without this the exclusive lease would sit held for the rest of a
|
|
5343
|
+
// turn that never got the VM — the very serialisation #1361 removes.
|
|
5344
|
+
const dropLease = () => {
|
|
5345
|
+
localVmLeaseFor(localVmTarget).release(claimThreadId);
|
|
5346
|
+
if (localVmActiveThreads.get(localVmTarget.key) === claimThreadId)
|
|
5347
|
+
localVmActiveThreads.delete(localVmTarget.key);
|
|
5348
|
+
localVmThreadTargets.delete(claimThreadId);
|
|
5349
|
+
// bindTurnComputer above also took the turn-level resource; a
|
|
5350
|
+
// later turn's exclusive bind queues behind it just the same.
|
|
5351
|
+
const resource = `computer:vm:${localVmTarget.key}`;
|
|
5352
|
+
turnResources.releaseOne(resource, resourceOwner);
|
|
5353
|
+
if (turnComputerResources.get(resourceOwner.threadId)?.resource === resource)
|
|
5354
|
+
turnComputerResources.delete(resourceOwner.threadId);
|
|
5355
|
+
};
|
|
5356
|
+
let localVm;
|
|
5357
|
+
try {
|
|
5358
|
+
localVm = await readyLocalVmForTurn(bot.id, localVmTarget);
|
|
5359
|
+
}
|
|
5360
|
+
catch (error) {
|
|
5361
|
+
dropLease();
|
|
5362
|
+
throw error;
|
|
5363
|
+
}
|
|
5364
|
+
if (!localVm.ready || !localVm.runtime) {
|
|
5365
|
+
dropLease();
|
|
5366
|
+
throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
|
|
5367
|
+
}
|
|
5368
|
+
// Same contract as the Box and VPS branches below: without this the
|
|
5369
|
+
// poller never starts, so the Local VM publishes no `screen` events
|
|
5370
|
+
// and every client that only has the stream (the phone) waits
|
|
5371
|
+
// forever. The web panel hid the gap by polling the screenshot
|
|
5372
|
+
// route itself.
|
|
5373
|
+
previewCapture = localVmPreviewFor(localVmTarget, claimThreadId);
|
|
5374
|
+
return { target: localVmTarget, runtime: localVm.runtime };
|
|
5375
|
+
};
|
|
5186
5376
|
// Explicit destinations are strict. In particular, Local VM must never
|
|
5187
5377
|
// fall through to host CUA and accidentally click on the user's Mac.
|
|
5188
5378
|
// The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
|
|
@@ -5197,6 +5387,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5197
5387
|
throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
|
|
5198
5388
|
}
|
|
5199
5389
|
const localVmTarget = localVmTargetForBot(bot.id);
|
|
5390
|
+
let lazyReadyVm = null;
|
|
5200
5391
|
if (!strict) {
|
|
5201
5392
|
// Nothing this process has ever seen for this target, and nobody is
|
|
5202
5393
|
// relying on an unattended run: do not pay for a runtime probe.
|
|
@@ -5207,43 +5398,50 @@ async function startTurn(botId, text, opts) {
|
|
|
5207
5398
|
return false;
|
|
5208
5399
|
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key))
|
|
5209
5400
|
return false;
|
|
5401
|
+
if (seen.ready && seen.runtime)
|
|
5402
|
+
lazyReadyVm = { runtime: seen.runtime };
|
|
5210
5403
|
}
|
|
5211
5404
|
try {
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5405
|
+
if (lazyReadyVm) {
|
|
5406
|
+
// Lazy exclusivity (issue #1361): a VM that is ready right now
|
|
5407
|
+
// mounts without claiming — screen-less Auto turns never touch
|
|
5408
|
+
// the lease, and the first screen tools/call fires the claim
|
|
5409
|
+
// through the computer-control gate. A VM that must be created
|
|
5410
|
+
// or recreated first keeps the eager claim below: the bridge
|
|
5411
|
+
// child needs the container to exist, and readyLocalVmForTurn
|
|
5412
|
+
// is what boots it.
|
|
5413
|
+
integrations.localComputer = containerComputerMcp(lazyReadyVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
|
|
5414
|
+
autoVmClaims.set(threadId, {
|
|
5415
|
+
owner: resourceOwner,
|
|
5416
|
+
lazy: true,
|
|
5417
|
+
claim: async () => {
|
|
5418
|
+
await claimAutoLocalVm(threadId, localVmTarget);
|
|
5419
|
+
// The dispatch-site poller start saw a null previewCapture
|
|
5420
|
+
// (this lazy mount runs before any claim exists), so this
|
|
5421
|
+
// turn would publish no live `screen` events and settle no
|
|
5422
|
+
// final computer frame. Restart the poller with the now-live
|
|
5423
|
+
// computer capture, keeping any browser capture and whether
|
|
5424
|
+
// this turn already touched its screen. Same still-running
|
|
5425
|
+
// guard as dispatch: a poller started after its own
|
|
5426
|
+
// turn.completed would never be torn down.
|
|
5427
|
+
if (previewCapture && threadBusy(bot.id, threadId)) {
|
|
5428
|
+
const touched = screenPollers.get(threadId)?.touched ?? instance.driverKind === "boxAgent";
|
|
5429
|
+
stopScreenPoller(bot.id, threadId);
|
|
5430
|
+
startScreenPoller(bot.id, threadId, { computer: previewCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
|
|
5431
|
+
}
|
|
5432
|
+
},
|
|
5433
|
+
});
|
|
5434
|
+
return true;
|
|
5228
5435
|
}
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
//
|
|
5232
|
-
//
|
|
5233
|
-
//
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
// bot's name — live and in the settled transcript frame, which is
|
|
5239
|
-
// taken after the lease is already released. No owner means the
|
|
5240
|
-
// desktop is simply idle: that final frame is ours to keep.
|
|
5241
|
-
const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
|
|
5242
|
-
if (owner && owner.threadId !== threadId) {
|
|
5243
|
-
throw new Error("the Local VM moved on to another turn");
|
|
5244
|
-
}
|
|
5245
|
-
return containerComputerFrame(undefined, undefined, localVmTarget);
|
|
5246
|
-
};
|
|
5436
|
+
const claimed = await claimAutoLocalVm(threadId);
|
|
5437
|
+
integrations.localComputer = containerComputerMcp(claimed.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), claimed.target);
|
|
5438
|
+
// Hand the same claim to the first-screen-call gate. This eager
|
|
5439
|
+
// path has already claimed, so the gate's fire-once call can only
|
|
5440
|
+
// re-assert the same owner — a no-op (issue #1361).
|
|
5441
|
+
autoVmClaims.set(threadId, {
|
|
5442
|
+
owner: resourceOwner,
|
|
5443
|
+
claim: async () => { await claimAutoLocalVm(threadId); },
|
|
5444
|
+
});
|
|
5247
5445
|
return true;
|
|
5248
5446
|
}
|
|
5249
5447
|
catch (error) {
|
|
@@ -5263,12 +5461,16 @@ async function startTurn(botId, text, opts) {
|
|
|
5263
5461
|
hostPlatform: process.platform,
|
|
5264
5462
|
providerSupportsLocal: mountsLocalComputer,
|
|
5265
5463
|
})) {
|
|
5266
|
-
|
|
5464
|
+
// Name the condition that actually failed: a person told "choose an
|
|
5465
|
+
// ACP engine" while already on one has nowhere to go.
|
|
5466
|
+
throw new Error(mountsLocalComputer
|
|
5467
|
+
? `local computer control is not available on ${process.platform} — select another destination`
|
|
5468
|
+
: "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
|
|
5267
5469
|
}
|
|
5268
5470
|
const cua = readCuaConnection();
|
|
5269
5471
|
if (!cua)
|
|
5270
5472
|
throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
|
|
5271
|
-
bindTurnComputer(resourceOwner, "computer:host");
|
|
5473
|
+
await bindTurnComputer(resourceOwner, "computer:host");
|
|
5272
5474
|
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
5273
5475
|
computerKind = "local";
|
|
5274
5476
|
}
|
|
@@ -5284,7 +5486,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5284
5486
|
if (!unsupported) {
|
|
5285
5487
|
// The remote lifecycle and container are shared by this bot. Keep
|
|
5286
5488
|
// its explicit computer turns serialized; ordinary threads still run.
|
|
5287
|
-
bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
|
|
5489
|
+
await bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
|
|
5288
5490
|
activeVpsThreads.set(bot.id, threadId);
|
|
5289
5491
|
let remote;
|
|
5290
5492
|
remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
|
|
@@ -5322,7 +5524,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5322
5524
|
// Explicit cloud turns can provision/wake the same bot's Box. Claim
|
|
5323
5525
|
// before any network await so setup itself cannot race another turn.
|
|
5324
5526
|
if (wants === "cloud")
|
|
5325
|
-
bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
|
|
5527
|
+
await bindTurnComputer(resourceOwner, `computer:box-bot:${bot.id}`, true);
|
|
5326
5528
|
if (!mountsCloudComputer && wants === "cloud") {
|
|
5327
5529
|
throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
|
|
5328
5530
|
}
|
|
@@ -5366,7 +5568,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5366
5568
|
});
|
|
5367
5569
|
}
|
|
5368
5570
|
if (b && lifecycle === "attach") {
|
|
5369
|
-
bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
|
|
5571
|
+
await bindTurnComputer(resourceOwner, `computer:box:${b.id}`, instance.driverKind === "boxAgent");
|
|
5370
5572
|
previewCapture = () => box.screenshotBox(cfg, bot.id, b.id);
|
|
5371
5573
|
if (mountsCloudComputer) {
|
|
5372
5574
|
integrations.computer = {
|
|
@@ -5407,7 +5609,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5407
5609
|
})) {
|
|
5408
5610
|
const cua = readCuaConnection();
|
|
5409
5611
|
if (cua) {
|
|
5410
|
-
bindTurnComputer(resourceOwner, "computer:host");
|
|
5612
|
+
await bindTurnComputer(resourceOwner, "computer:host");
|
|
5411
5613
|
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
5412
5614
|
computerKind = "local";
|
|
5413
5615
|
}
|
|
@@ -5771,6 +5973,8 @@ function routineRunCard(run) {
|
|
|
5771
5973
|
};
|
|
5772
5974
|
if (run.goalStatus)
|
|
5773
5975
|
card.goalStatus = run.goalStatus;
|
|
5976
|
+
if (run.deferredAt != null && run.status === "queued")
|
|
5977
|
+
card.deferredAt = run.deferredAt;
|
|
5774
5978
|
if (run.threadId)
|
|
5775
5979
|
card.executionThreadId = run.threadId;
|
|
5776
5980
|
if (summary)
|
|
@@ -5801,7 +6005,9 @@ function routineRunFallbackText(card) {
|
|
|
5801
6005
|
? "was cancelled"
|
|
5802
6006
|
: card.status === "missed"
|
|
5803
6007
|
? "was missed"
|
|
5804
|
-
: card.status
|
|
6008
|
+
: card.status === "queued" && card.deferredAt != null
|
|
6009
|
+
? "deferred: target busy"
|
|
6010
|
+
: card.status);
|
|
5805
6011
|
return `Routine “${card.routineName}” ${state}`;
|
|
5806
6012
|
}
|
|
5807
6013
|
/** Upsert one durable lifecycle card per run. Replaying the same transition,
|
|
@@ -5922,10 +6128,7 @@ routines = new RoutineManager({
|
|
|
5922
6128
|
hasPendingDelegations: (threadId) => pendingThreads().includes(threadId) ||
|
|
5923
6129
|
[...delegationWatch.values()].some((watch) => watch.sourceThreadId === threadId) ||
|
|
5924
6130
|
pendingDelegationWakes.has(threadId),
|
|
5925
|
-
botState:
|
|
5926
|
-
const bot = store.bot(botId);
|
|
5927
|
-
return !bot ? "missing" : bot.busy ? "busy" : "ready";
|
|
5928
|
-
},
|
|
6131
|
+
botState: unattendedDispatchState,
|
|
5929
6132
|
goalState: (groupId, coordinatorBotId) => {
|
|
5930
6133
|
const group = store.group(groupId);
|
|
5931
6134
|
const coordinator = store.bot(coordinatorBotId);
|
|
@@ -6005,6 +6208,17 @@ routines = new RoutineManager({
|
|
|
6005
6208
|
const notificationBot = routineSourceOwner(run)?.bot ?? bot;
|
|
6006
6209
|
notify(buildNotification("routine-failed", notificationBot, routineSourceThread(run) ?? run.threadId ?? bot.threadId, detail));
|
|
6007
6210
|
},
|
|
6211
|
+
onRunDeferred: (run) => {
|
|
6212
|
+
const bot = store.bot(run.botId);
|
|
6213
|
+
if (!bot)
|
|
6214
|
+
return;
|
|
6215
|
+
const minutes = run.deferredAt != null && run.deferredNoticeAt != null
|
|
6216
|
+
? Math.max(1, Math.round((run.deferredNoticeAt - run.deferredAt) / 60_000))
|
|
6217
|
+
: null;
|
|
6218
|
+
const detail = `${redactSecretsInText(run.routineName)}: target busy${minutes != null ? ` for ${minutes} minutes` : ""}`;
|
|
6219
|
+
const notificationBot = routineSourceOwner(run)?.bot ?? bot;
|
|
6220
|
+
notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
|
|
6221
|
+
},
|
|
6008
6222
|
});
|
|
6009
6223
|
// The scheduler receipt and room transcript live in separate durable stores.
|
|
6010
6224
|
// If the process exited between those two writes, prefer the correlated
|
|
@@ -6295,12 +6509,6 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
6295
6509
|
const acknowledged = await browserCleanup.ensure(committedCleanup);
|
|
6296
6510
|
requireBrowserCleanupAcknowledged(acknowledged, `Browser data for ${bot.name}`);
|
|
6297
6511
|
}
|
|
6298
|
-
for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
|
|
6299
|
-
try {
|
|
6300
|
-
unlinkSync(join(dir, `${bot.threadId}.ndjson`));
|
|
6301
|
-
}
|
|
6302
|
-
catch { }
|
|
6303
|
-
}
|
|
6304
6512
|
return deletionResponse(200, { ok: true });
|
|
6305
6513
|
}
|
|
6306
6514
|
finally {
|
|
@@ -6609,10 +6817,7 @@ function resolveAndSendProfile(res, args) {
|
|
|
6609
6817
|
// ordered behind a busy MAUS and gives webhook runs the same durable receipts.
|
|
6610
6818
|
const webhooks = new WebhookManager({
|
|
6611
6819
|
emit: broadcast,
|
|
6612
|
-
botState:
|
|
6613
|
-
const bot = store.bot(botId);
|
|
6614
|
-
return !bot ? "missing" : bot.busy ? "busy" : "ready";
|
|
6615
|
-
},
|
|
6820
|
+
botState: unattendedDispatchState,
|
|
6616
6821
|
enqueue: (input) => routines.enqueueWebhook(input),
|
|
6617
6822
|
findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
|
|
6618
6823
|
cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
|
|
@@ -6747,7 +6952,11 @@ setupRetry = 0) {
|
|
|
6747
6952
|
}
|
|
6748
6953
|
revokeInternalCapabilitiesForThread(threadId);
|
|
6749
6954
|
spoken.add(botId);
|
|
6750
|
-
|
|
6955
|
+
// Must be the SAME resolver the readiness re-check uses below, or a Chief's
|
|
6956
|
+
// delegated Full elevation makes the two disagree by construction: every
|
|
6957
|
+
// such room turn then reads as "settings changed", retries once, and
|
|
6958
|
+
// settles as busy without ever dispatching.
|
|
6959
|
+
const preparedApprovalMode = roomTurnApprovalMode(bot, orchestration);
|
|
6751
6960
|
const preparedSelection = { ...bot.modelSelection };
|
|
6752
6961
|
const preparedComposio = bot.composio;
|
|
6753
6962
|
const instance = turnInstance(bot);
|
|
@@ -6875,7 +7084,7 @@ setupRetry = 0) {
|
|
|
6875
7084
|
if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
|
|
6876
7085
|
return false;
|
|
6877
7086
|
const setupChanged = turnInstance(readyBot) !== instance ||
|
|
6878
|
-
|
|
7087
|
+
roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
|
|
6879
7088
|
readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
|
|
6880
7089
|
readyBot.modelSelection.model !== preparedSelection.model ||
|
|
6881
7090
|
readyBot.modelSelection.effort !== preparedSelection.effort ||
|
|
@@ -7009,7 +7218,7 @@ setupRetry = 0) {
|
|
|
7009
7218
|
}
|
|
7010
7219
|
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
7011
7220
|
const target = { ...localVmTargetForBot(readyBot.id) };
|
|
7012
|
-
bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
7221
|
+
await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
7013
7222
|
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
|
|
7014
7223
|
throw new Error("this Local VM is being started, stopped, or replaced");
|
|
7015
7224
|
}
|
|
@@ -7248,7 +7457,7 @@ setupRetry = 0) {
|
|
|
7248
7457
|
text,
|
|
7249
7458
|
refreshSystemPrompt: true,
|
|
7250
7459
|
images: turnImages,
|
|
7251
|
-
approvalMode:
|
|
7460
|
+
approvalMode: roomTurnApprovalMode(readyBot, orchestration),
|
|
7252
7461
|
system: roomSystem.text,
|
|
7253
7462
|
systemStable: roomSystem.stable,
|
|
7254
7463
|
systemVolatile: roomSystem.volatile,
|
|
@@ -7449,6 +7658,8 @@ setupRetry = 0) {
|
|
|
7449
7658
|
catch (error) {
|
|
7450
7659
|
if (providerDispatched)
|
|
7451
7660
|
throw error;
|
|
7661
|
+
if (error instanceof DirectTurnSetupCancelled)
|
|
7662
|
+
return false;
|
|
7452
7663
|
const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
|
|
7453
7664
|
const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
|
|
7454
7665
|
if (!roomSpeaker && !isSpendCap && !isVariantError)
|
|
@@ -10359,19 +10570,27 @@ const handleRequest = async (req, res) => {
|
|
|
10359
10570
|
if (method === "POST" && path === "/api/internal/ask-bot") {
|
|
10360
10571
|
const body = await readInternalBody();
|
|
10361
10572
|
const fromBotId = internalSender.id;
|
|
10362
|
-
const
|
|
10573
|
+
const toBotRef = String(body.toBotId ?? "");
|
|
10363
10574
|
const message = String(body.message ?? "").trim();
|
|
10364
10575
|
if (body.depth !== undefined &&
|
|
10365
10576
|
(!Number.isInteger(body.depth) || body.depth < 0 || body.depth !== internalCapability.depth)) {
|
|
10366
10577
|
return json(res, 403, { error: "the recursion depth does not match this turn" });
|
|
10367
10578
|
}
|
|
10368
10579
|
const depth = internalCapability.depth;
|
|
10369
|
-
if (!
|
|
10580
|
+
if (!toBotRef || !message)
|
|
10370
10581
|
return json(res, 400, { error: "toBotId and message required" });
|
|
10371
|
-
if (
|
|
10582
|
+
if (toBotRef === fromBotId)
|
|
10372
10583
|
return json(res, 400, { error: "a bot cannot message itself" });
|
|
10373
10584
|
if (depth >= MAX_COMMS_DEPTH)
|
|
10374
10585
|
return json(res, 200, { error: "message chains are limited to one hop" });
|
|
10586
|
+
// A unique reachable teammate name is accepted where an id is
|
|
10587
|
+
// expected; see resolveTeammate for why.
|
|
10588
|
+
const resolvedTo = resolveTeammate(store.bots, internalSender, toBotRef);
|
|
10589
|
+
if ("error" in resolvedTo)
|
|
10590
|
+
return json(res, 404, { error: `no such bot: ${resolvedTo.error}` });
|
|
10591
|
+
if (resolvedTo.id === fromBotId)
|
|
10592
|
+
return json(res, 400, { error: "a bot cannot message itself" });
|
|
10593
|
+
const toBotId = resolvedTo.id;
|
|
10375
10594
|
const target = store.bot(toBotId);
|
|
10376
10595
|
if (!target)
|
|
10377
10596
|
return json(res, 404, { error: "no such bot" });
|
|
@@ -10574,7 +10793,7 @@ const handleRequest = async (req, res) => {
|
|
|
10574
10793
|
}
|
|
10575
10794
|
if (method === "POST" && path === "/api/internal/delegate-bot") {
|
|
10576
10795
|
const body = await readInternalBody();
|
|
10577
|
-
const
|
|
10796
|
+
const toBotRef = String(body.toBotId ?? "");
|
|
10578
10797
|
const message = String(body.message ?? "").trim();
|
|
10579
10798
|
const reason = typeof body.reason === "string" && body.reason.trim() ? body.reason.trim() : undefined;
|
|
10580
10799
|
if (body.depth !== undefined &&
|
|
@@ -10582,9 +10801,13 @@ const handleRequest = async (req, res) => {
|
|
|
10582
10801
|
return json(res, 403, { error: "the recursion depth does not match this turn" });
|
|
10583
10802
|
}
|
|
10584
10803
|
const depth = internalCapability.depth;
|
|
10585
|
-
if (!
|
|
10804
|
+
if (!toBotRef || !message)
|
|
10586
10805
|
return json(res, 400, { error: "toBotId and message required" });
|
|
10587
10806
|
const from = internalSender;
|
|
10807
|
+
const resolvedTo = resolveTeammate(store.bots, from, toBotRef);
|
|
10808
|
+
if ("error" in resolvedTo)
|
|
10809
|
+
return json(res, 404, { error: `no such bot: ${resolvedTo.error}` });
|
|
10810
|
+
const toBotId = resolvedTo.id;
|
|
10588
10811
|
const target = store.bot(toBotId);
|
|
10589
10812
|
if (!target)
|
|
10590
10813
|
return json(res, 404, { error: "no such bot" });
|
|
@@ -10655,11 +10878,35 @@ const handleRequest = async (req, res) => {
|
|
|
10655
10878
|
const destination = groupId ? store.group(groupId) : undefined;
|
|
10656
10879
|
if (groupId && !destination)
|
|
10657
10880
|
return json(res, 404, { error: "No such room; use list_room_targets." });
|
|
10658
|
-
|
|
10881
|
+
// A slot may carry a teammate's name instead of its id — the
|
|
10882
|
+
// roster shows both, list_bots shows both, and a Chief reading its
|
|
10883
|
+
// prompt reaches for the name. A unique reachable name resolves;
|
|
10884
|
+
// anything else is refused with the id or name the caller sent
|
|
10885
|
+
// and the way to the real ids (peer-roster.ts).
|
|
10886
|
+
const botIds = [];
|
|
10887
|
+
for (const raw of parsed.data.botIds) {
|
|
10888
|
+
const resolved = resolveTeammate(store.bots, internalSender, raw);
|
|
10889
|
+
if ("error" in resolved)
|
|
10890
|
+
return json(res, 403, { error: resolved.error });
|
|
10891
|
+
botIds.push(resolved.id);
|
|
10892
|
+
}
|
|
10893
|
+
if (new Set(botIds).size !== botIds.length)
|
|
10894
|
+
return json(res, 400, { error: "bot_ids name the same teammate twice — send each teammate once" });
|
|
10895
|
+
const targets = botIds.map(botId => ({ groupId: destination?.id,
|
|
10659
10896
|
threadId: destination ? destination.id === source?.id ? address.threadId : destination.threadId : store.bot(botId)?.threadId ?? "", botId,
|
|
10660
10897
|
}));
|
|
10661
10898
|
for (const target of targets) {
|
|
10662
|
-
|
|
10899
|
+
// roomHandoffProblem's "no longer exists" is written for a route
|
|
10900
|
+
// that was valid and went away. Here the id is the model's own
|
|
10901
|
+
// argument — usually a display name dropped into a bot_ids slot —
|
|
10902
|
+
// so say which id failed and where the real ones are, instead of
|
|
10903
|
+
// telling the model a teammate it can still reach is gone. Only
|
|
10904
|
+
// the id the caller sent is echoed back, never a bot's name.
|
|
10905
|
+
const addressed = store.bot(target.botId);
|
|
10906
|
+
const eligibility = target.botId === internalSender.id ? "Choose a teammate, not yourself"
|
|
10907
|
+
: !addressed ? `No bot with id "${target.botId}" — call list_bots and copy the exact id from the result`
|
|
10908
|
+
: addressed.hidden ? `The bot with id "${target.botId}" is no longer available — call list_bots for the ones you can reach`
|
|
10909
|
+
: roomHandoffProblem(target, address);
|
|
10663
10910
|
if (eligibility)
|
|
10664
10911
|
return json(res, 403, { error: eligibility });
|
|
10665
10912
|
}
|
|
@@ -10695,6 +10942,9 @@ const handleRequest = async (req, res) => {
|
|
|
10695
10942
|
target.threadId = resolved.task.threadId;
|
|
10696
10943
|
if (resolved.created)
|
|
10697
10944
|
createdThread = resolved.task.threadId;
|
|
10945
|
+
if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
|
|
10946
|
+
grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
|
|
10947
|
+
}
|
|
10698
10948
|
}
|
|
10699
10949
|
const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "");
|
|
10700
10950
|
// A re-dispatched request_key is answered by the request it
|
|
@@ -10963,6 +11213,8 @@ const handleRequest = async (req, res) => {
|
|
|
10963
11213
|
const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
10964
11214
|
if (!task)
|
|
10965
11215
|
return json(res, 500, { error: "couldn't create that thread" });
|
|
11216
|
+
if (delegatedFullAccess(from, fromThreadId, target))
|
|
11217
|
+
grantDelegatedFullAccess(from, target, task.threadId);
|
|
10966
11218
|
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
|
|
10967
11219
|
if (queued.result !== "ok" || !queued.id) {
|
|
10968
11220
|
// nothing will ever run there: take the row back before the person
|
|
@@ -11209,6 +11461,48 @@ const handleRequest = async (req, res) => {
|
|
|
11209
11461
|
return json(res, 404, { error: "no such bot" });
|
|
11210
11462
|
if (method === "GET") {
|
|
11211
11463
|
const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
|
|
11464
|
+
const slot = autoVmClaims.get(internalCapability.threadId);
|
|
11465
|
+
const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
|
|
11466
|
+
if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
|
|
11467
|
+
// First screen tools/call on a lazily-attached Auto VM (issue
|
|
11468
|
+
// #1361): fire the exclusive claim — once — and give it a moment
|
|
11469
|
+
// to land. A free, ready VM claims in the time of one container
|
|
11470
|
+
// inspect, so this call then proceeds with an honest answer;
|
|
11471
|
+
// only a claim still queued behind another holder answers held
|
|
11472
|
+
// below, and then the contention text is true. Keyed on the
|
|
11473
|
+
// slot, never on the thread's turn-computer entry: a bind this
|
|
11474
|
+
// turn abandoned earlier (a VPS that turned out to be asleep)
|
|
11475
|
+
// must not hide the unclaimed VM and let the call through.
|
|
11476
|
+
startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
|
|
11477
|
+
await Promise.race([
|
|
11478
|
+
lazyClaim.begin ?? Promise.resolve(),
|
|
11479
|
+
new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
|
|
11480
|
+
]);
|
|
11481
|
+
}
|
|
11482
|
+
if (!snapshot.held && lazyClaim?.failed === true) {
|
|
11483
|
+
// A rejected lazy claim (gate finding F1, issue #1361): the
|
|
11484
|
+
// computer MCP mounted at dispatch is still live, and the claim
|
|
11485
|
+
// may even have left a turn-computer entry behind (it can reject
|
|
11486
|
+
// after bindTurnComputer succeeded — lease lost to a person,
|
|
11487
|
+
// lifecycle busy, boot failure). Either way this turn owns no
|
|
11488
|
+
// usable VM, so keep refusing every screen call for the rest of
|
|
11489
|
+
// the generation; the bridge must never forward one onto a VM
|
|
11490
|
+
// this turn never claimed. Turn settle GC clears the slot. Say
|
|
11491
|
+
// why, and say not to retry: the contention text would send the
|
|
11492
|
+
// model into a screenshot loop against a claim that cannot land.
|
|
11493
|
+
return json(res, 200, {
|
|
11494
|
+
held: true, helpOpen: false,
|
|
11495
|
+
blockedReason: `This turn could not claim the Local VM${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
|
+
});
|
|
11497
|
+
}
|
|
11498
|
+
if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
|
|
11499
|
+
// The claim fired and is still waiting on the exclusive bind:
|
|
11500
|
+
// another turn genuinely holds this desktop right now.
|
|
11501
|
+
return json(res, 200, {
|
|
11502
|
+
held: true, helpOpen: false,
|
|
11503
|
+
blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
|
|
11504
|
+
});
|
|
11505
|
+
}
|
|
11212
11506
|
const computer = turnComputerResources.get(internalCapability.threadId);
|
|
11213
11507
|
if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
|
|
11214
11508
|
!claimTurnResource(computer.owner, computer.resource)) {
|
|
@@ -12518,14 +12812,6 @@ const handleRequest = async (req, res) => {
|
|
|
12518
12812
|
routines.disableForGroup(group.id);
|
|
12519
12813
|
store.deleteGroup(group.id);
|
|
12520
12814
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
12521
|
-
for (const threadId of threadIds) {
|
|
12522
|
-
for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
|
|
12523
|
-
try {
|
|
12524
|
-
unlinkSync(join(dir, `${threadId}.ndjson`));
|
|
12525
|
-
}
|
|
12526
|
-
catch { }
|
|
12527
|
-
}
|
|
12528
|
-
}
|
|
12529
12815
|
return json(res, 200, { ok: true });
|
|
12530
12816
|
}
|
|
12531
12817
|
m = path.match(/^\/api\/groups\/([\w-]+)\/messages$/);
|
|
@@ -12633,6 +12919,112 @@ const handleRequest = async (req, res) => {
|
|
|
12633
12919
|
}
|
|
12634
12920
|
return json(res, 200, { ok: true });
|
|
12635
12921
|
}
|
|
12922
|
+
// Steer a queued room message into the RUNNING room turn (no interrupt).
|
|
12923
|
+
// Only the head steers — room queues drain one item at a time — and the
|
|
12924
|
+
// engine that receives it is the thread's live speaker. A room whose
|
|
12925
|
+
// running driver cannot steer keeps its queue, exactly like an incapable
|
|
12926
|
+
// 1:1 engine; this never ends the running turn.
|
|
12927
|
+
m = path.match(/^\/api\/groups\/([\w-]+)\/queue\/([\w-]+)\/steer$/);
|
|
12928
|
+
if (m && method === "POST") {
|
|
12929
|
+
const body = await readBody(req);
|
|
12930
|
+
if (body !== null && (typeof body !== "object" || Array.isArray(body))) {
|
|
12931
|
+
return json(res, 400, { error: "body must be a JSON object" });
|
|
12932
|
+
}
|
|
12933
|
+
const threadId = typeof body?.threadId === "string" ? body.threadId : undefined;
|
|
12934
|
+
if (threadId !== undefined && !/^[\w-]+$/.test(threadId)) {
|
|
12935
|
+
return json(res, 400, { error: "threadId must be a task id" });
|
|
12936
|
+
}
|
|
12937
|
+
const group = store.group(m[1]);
|
|
12938
|
+
if (!group)
|
|
12939
|
+
return json(res, 404, { error: "no such room" });
|
|
12940
|
+
const targetThreadId = threadId ?? group.threadId;
|
|
12941
|
+
const ownsThread = group.dm
|
|
12942
|
+
? group.threadId === targetThreadId
|
|
12943
|
+
: Boolean(store.groupTaskByThread(group.id, targetThreadId));
|
|
12944
|
+
if (!ownsThread) {
|
|
12945
|
+
return json(res, 409, { error: "the channel switched tasks before it could receive the message" });
|
|
12946
|
+
}
|
|
12947
|
+
noteTurnTrigger(targetThreadId, auth);
|
|
12948
|
+
const current = store.group(group.id);
|
|
12949
|
+
if (!current)
|
|
12950
|
+
return json(res, 404, { error: "no such room" });
|
|
12951
|
+
// Which engine owns the running room turn on this thread? The same
|
|
12952
|
+
// resolution the room's own Stop uses: the live speaker, else the busy
|
|
12953
|
+
// bot on the channel's main thread.
|
|
12954
|
+
const speakerBotId = groupSpeakers.get(targetThreadId)?.botId ??
|
|
12955
|
+
(targetThreadId === current.threadId ? current.busyBotId : undefined);
|
|
12956
|
+
const speaker = speakerBotId ? store.bot(speakerBotId) : undefined;
|
|
12957
|
+
const instance = speaker ? runningTurnInstance(speaker, targetThreadId) : undefined;
|
|
12958
|
+
// Lift the queue atomically: the room settling can drain it as the
|
|
12959
|
+
// next follow-up, or this request can steer its head into the live
|
|
12960
|
+
// turn — never both for the same words.
|
|
12961
|
+
const held = holdChannelQueue(current.id, targetThreadId, m[2]);
|
|
12962
|
+
if (!held)
|
|
12963
|
+
return json(res, 404, { error: "no such queued message" });
|
|
12964
|
+
if (!speaker || !instance?.adapter.capabilities.queueing || !instance.adapter.steer) {
|
|
12965
|
+
restoreHeldChannelQueue(held);
|
|
12966
|
+
return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
|
|
12967
|
+
}
|
|
12968
|
+
const [head] = held.items;
|
|
12969
|
+
if (!head || head.id !== m[2]) {
|
|
12970
|
+
restoreHeldChannelQueue(held);
|
|
12971
|
+
return json(res, 409, { error: "only the first queued message can steer" });
|
|
12972
|
+
}
|
|
12973
|
+
// A reply target that cannot be resolved restores the held queue
|
|
12974
|
+
// before the request fails — the room's normal drain keeps the head.
|
|
12975
|
+
const replyTo = resolveHeldReplyTarget(held, resolveReplyTarget);
|
|
12976
|
+
const steered = await instance.adapter
|
|
12977
|
+
.steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
|
|
12978
|
+
.catch(() => "indeterminate");
|
|
12979
|
+
// The steer was awaited adapter work: re-read every ownership
|
|
12980
|
+
// invariant before writing anything, exactly like the 1:1 path. A
|
|
12981
|
+
// speaker change, a channel switch, or a settled room restores the
|
|
12982
|
+
// queue instead of recording words the new turn never saw.
|
|
12983
|
+
const after = store.group(current.id);
|
|
12984
|
+
const afterSpeakerBotId = after
|
|
12985
|
+
? groupSpeakers.get(targetThreadId)?.botId ??
|
|
12986
|
+
(targetThreadId === after.threadId ? after.busyBotId : undefined)
|
|
12987
|
+
: undefined;
|
|
12988
|
+
// "indeterminate" (timeout after delivery, lost transport, a settle
|
|
12989
|
+
// race) never restores: the words may already be folded into the turn
|
|
12990
|
+
// that was live when they were sent, and replaying them into a new
|
|
12991
|
+
// turn would run them twice. Record them once — even under a new
|
|
12992
|
+
// speaker — and settle the head.
|
|
12993
|
+
const delivered = steered !== "refused";
|
|
12994
|
+
if (after && delivered && (steered === "indeterminate" || afterSpeakerBotId === speakerBotId)) {
|
|
12995
|
+
const message = store.appendMessage(targetThreadId, {
|
|
12996
|
+
role: "user",
|
|
12997
|
+
kind: "text",
|
|
12998
|
+
text: head.text,
|
|
12999
|
+
replyToId: head.replyToId,
|
|
13000
|
+
sendId: head.sendId,
|
|
13001
|
+
channelMode: head.mode,
|
|
13002
|
+
queueId: head.id,
|
|
13003
|
+
via: head.via,
|
|
13004
|
+
steered: true,
|
|
13005
|
+
});
|
|
13006
|
+
settleHeldChannelQueueHead(held);
|
|
13007
|
+
return json(res, 200, {
|
|
13008
|
+
ok: true,
|
|
13009
|
+
steered: true,
|
|
13010
|
+
threadId: targetThreadId,
|
|
13011
|
+
messages: [message],
|
|
13012
|
+
queueIds: [head.id],
|
|
13013
|
+
});
|
|
13014
|
+
}
|
|
13015
|
+
if (steered === "indeterminate" && !after) {
|
|
13016
|
+
// The room vanished while the answer was lost: settle the head so a
|
|
13017
|
+
// restart cannot replay words the dead turn may already have run.
|
|
13018
|
+
settleHeldChannelQueueHead(held);
|
|
13019
|
+
return json(res, 404, { error: "no such room" });
|
|
13020
|
+
}
|
|
13021
|
+
restoreHeldChannelQueue(held);
|
|
13022
|
+
// The room may have settled while the steer was refused; a queue that
|
|
13023
|
+
// is now drainable must not strand behind a missed settle.
|
|
13024
|
+
if (after && !groupIsWorking(after))
|
|
13025
|
+
drainQueuedChannelSends();
|
|
13026
|
+
return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
|
|
13027
|
+
}
|
|
12636
13028
|
m = path.match(/^\/api\/groups\/([\w-]+)\/interrupt$/);
|
|
12637
13029
|
if (m && method === "POST") {
|
|
12638
13030
|
const group = store.group(m[1]);
|
|
@@ -14006,7 +14398,7 @@ const handleRequest = async (req, res) => {
|
|
|
14006
14398
|
// existing server-side queue records it atomically for the next turn.
|
|
14007
14399
|
if (currentAtStart.busy) {
|
|
14008
14400
|
const instance = runningTurnInstance(currentAtStart, threadId);
|
|
14009
|
-
let steered =
|
|
14401
|
+
let steered = "refused";
|
|
14010
14402
|
// A live text steer has no image side channel. Keep an attachment
|
|
14011
14403
|
// message intact for the next ordinary turn, where central image
|
|
14012
14404
|
// admission can hand it to the provider natively.
|
|
@@ -14014,7 +14406,7 @@ const handleRequest = async (req, res) => {
|
|
|
14014
14406
|
if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14015
14407
|
steered = await instance.adapter
|
|
14016
14408
|
.steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
|
|
14017
|
-
.catch(() =>
|
|
14409
|
+
.catch(() => "indeterminate");
|
|
14018
14410
|
}
|
|
14019
14411
|
// steer() is awaited adapter work. The turn can settle, the task can
|
|
14020
14412
|
// switch, or the whole bot can be deleted before its acknowledgement
|
|
@@ -14028,10 +14420,15 @@ const handleRequest = async (req, res) => {
|
|
|
14028
14420
|
if (!store.taskByThread(bot.id, threadId)) {
|
|
14029
14421
|
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
14030
14422
|
}
|
|
14031
|
-
|
|
14032
|
-
|
|
14423
|
+
const delivered = steered !== "refused";
|
|
14424
|
+
if (delivered) {
|
|
14425
|
+
if (steered === "steered" && !current.busy) {
|
|
14033
14426
|
throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
|
|
14034
14427
|
}
|
|
14428
|
+
// "indeterminate" falls through to the same record: the words
|
|
14429
|
+
// may already be folded into a turn whose acknowledgement was
|
|
14430
|
+
// lost, and handing them back for a resend could run them
|
|
14431
|
+
// twice. Recording them once is the honest outcome.
|
|
14035
14432
|
// A person steering a webhook turn is present, and auto mode may
|
|
14036
14433
|
// follow them again. But this route is also reachable from the
|
|
14037
14434
|
// bot's own shell on a headless server (loopback is the owner
|
|
@@ -14077,6 +14474,77 @@ const handleRequest = async (req, res) => {
|
|
|
14077
14474
|
}
|
|
14078
14475
|
return json(res, 200, { ok: true });
|
|
14079
14476
|
}
|
|
14477
|
+
// Steer a queued message into the RUNNING turn (no interrupt). Engines
|
|
14478
|
+
// without a live steer keep the queue; this never ends the current turn.
|
|
14479
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)\/steer$/);
|
|
14480
|
+
if (m && method === "POST") {
|
|
14481
|
+
const body = await readBody(req);
|
|
14482
|
+
requirePinnedClientThread(m[1], body?.threadId);
|
|
14483
|
+
const bot = requestedTaskBot(m[1], body?.threadId);
|
|
14484
|
+
noteTurnTrigger(bot.threadId, auth);
|
|
14485
|
+
// Lift the whole queue atomically: a settle racing this request can
|
|
14486
|
+
// drain it as a follow-up, or this request can steer it into the live
|
|
14487
|
+
// turn — never both for the same words.
|
|
14488
|
+
const held = holdSteeredQueue(bot.id, bot.threadId, m[2]);
|
|
14489
|
+
if (!held)
|
|
14490
|
+
return json(res, 404, { error: "no such queued message" });
|
|
14491
|
+
// A live steer has no image side channel. Attachment words wait for a
|
|
14492
|
+
// real turn where central admission can hand the images to the engine.
|
|
14493
|
+
if (held.items.some((item) => extractTurnImages(item.text).images.length > 0)) {
|
|
14494
|
+
restoreHeldSteeredQueue(held);
|
|
14495
|
+
return json(res, 200, { ok: true, queued: true, threadId: bot.threadId });
|
|
14496
|
+
}
|
|
14497
|
+
const currentAtStart = store.projectBotForTask(bot.id, bot.threadId);
|
|
14498
|
+
const instance = currentAtStart?.busy ? runningTurnInstance(currentAtStart, bot.threadId) : undefined;
|
|
14499
|
+
const prompt = held.items.map((item) => item.prompt).join("\n\n");
|
|
14500
|
+
let steered = "refused";
|
|
14501
|
+
if (currentAtStart?.busy && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14502
|
+
steered = await instance.adapter
|
|
14503
|
+
.steer(bot.threadId, prompt)
|
|
14504
|
+
.catch(() => "indeterminate");
|
|
14505
|
+
}
|
|
14506
|
+
// The steer was awaited adapter work: re-read every ownership
|
|
14507
|
+
// invariant before writing anything, exactly like the live-send path.
|
|
14508
|
+
const current = store.projectBotForTask(bot.id, bot.threadId);
|
|
14509
|
+
// "indeterminate" never restores: the words may already be folded into
|
|
14510
|
+
// the turn that was live when they were sent, and replaying them into
|
|
14511
|
+
// a fresh follow-up turn would run them twice. Record them whenever
|
|
14512
|
+
// the destination still exists, busy or not.
|
|
14513
|
+
if ((steered === "steered" && current?.busy ||
|
|
14514
|
+
steered === "indeterminate" && current) &&
|
|
14515
|
+
store.taskByThread(bot.id, bot.threadId)) {
|
|
14516
|
+
if (auth.kind === "session" || DESKTOP_MANAGED)
|
|
14517
|
+
clearUnattended(bot.threadId);
|
|
14518
|
+
const messages = held.items.map((item) => store.appendMessage(bot.threadId, {
|
|
14519
|
+
role: "user",
|
|
14520
|
+
kind: "text",
|
|
14521
|
+
text: item.text,
|
|
14522
|
+
replyToId: item.replyToId,
|
|
14523
|
+
sendId: item.sendId,
|
|
14524
|
+
queueId: item.messageId,
|
|
14525
|
+
peerAsk: item.peerAsk,
|
|
14526
|
+
steered: true,
|
|
14527
|
+
}));
|
|
14528
|
+
const queueIds = held.items.map((item) => item.messageId);
|
|
14529
|
+
settleHeldSteeredQueue(held);
|
|
14530
|
+
return json(res, 200, { ok: true, steered: true, threadId: bot.threadId, messages, queueIds });
|
|
14531
|
+
}
|
|
14532
|
+
if (steered === "indeterminate") {
|
|
14533
|
+
// No destination is left: settle so a restart cannot replay words a
|
|
14534
|
+
// dead turn may already have run.
|
|
14535
|
+
settleHeldSteeredQueue(held);
|
|
14536
|
+
if (!store.taskByThread(bot.id, bot.threadId)) {
|
|
14537
|
+
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
14538
|
+
}
|
|
14539
|
+
throw Object.assign(new Error("no such bot"), { status: 404 });
|
|
14540
|
+
}
|
|
14541
|
+
restoreHeldSteeredQueue(held);
|
|
14542
|
+
// The turn may have settled while the steer was refused; a queue that
|
|
14543
|
+
// is now drainable must not strand behind a missed settle.
|
|
14544
|
+
if (current && !current.busy)
|
|
14545
|
+
drainQueuedSends();
|
|
14546
|
+
return json(res, 200, { ok: true, queued: true, threadId: bot.threadId });
|
|
14547
|
+
}
|
|
14080
14548
|
// edit a user message → fork the conversation there and rerun the turn.
|
|
14081
14549
|
// Rewinding a live thread is refused, exactly like switching versions
|
|
14082
14550
|
// below: interrupting mid-flight and branching under the dying turn is
|
|
@@ -16415,6 +16883,37 @@ try {
|
|
|
16415
16883
|
catch (error) {
|
|
16416
16884
|
console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
16417
16885
|
}
|
|
16886
|
+
// #1280: retention for per-thread event logs. Off unless configured, and
|
|
16887
|
+
// even then it only removes log files — transcripts, thread records, and
|
|
16888
|
+
// workspace state stay untouched. A thread qualifies only when its newest
|
|
16889
|
+
// close or archive stamp is older than the window and it is not busy,
|
|
16890
|
+
// unread, or carrying an open direct handoff.
|
|
16891
|
+
const THREAD_LOG_RETENTION_SWEEP_MS = 24 * 60 * 60 * 1000;
|
|
16892
|
+
function sweepThreadEventLogsNow() {
|
|
16893
|
+
const retentionDays = threadEventLogRetentionDays(cfg);
|
|
16894
|
+
if (retentionDays === null)
|
|
16895
|
+
return;
|
|
16896
|
+
const candidates = store.bots.flatMap((bot) => (bot.tasks ?? []).map((task) => ({
|
|
16897
|
+
threadId: task.threadId,
|
|
16898
|
+
closedAt: task.closedBy?.at ?? null,
|
|
16899
|
+
archivedAt: task.archivedAt ?? null,
|
|
16900
|
+
unread: task.unread === true,
|
|
16901
|
+
busy: threadBusy(bot.id, task.threadId),
|
|
16902
|
+
openDirectHandoff: roomHandoffs.activeDirect(task.threadId),
|
|
16903
|
+
})));
|
|
16904
|
+
const swept = sweepThreadEventLogs(candidates, retentionDays);
|
|
16905
|
+
if (swept > 0)
|
|
16906
|
+
console.log(`[retention] removed event logs for ${swept} idle thread(s) past ${retentionDays} day(s)`);
|
|
16907
|
+
}
|
|
16908
|
+
try {
|
|
16909
|
+
sweepThreadEventLogsNow();
|
|
16910
|
+
}
|
|
16911
|
+
catch (error) {
|
|
16912
|
+
console.warn(`thread event log retention sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
16913
|
+
}
|
|
16914
|
+
// A days-scale window needs no tighter cadence; unref so the timer never
|
|
16915
|
+
// holds the process open.
|
|
16916
|
+
setInterval(sweepThreadEventLogsNow, THREAD_LOG_RETENTION_SWEEP_MS).unref();
|
|
16418
16917
|
// A dispatch claim is deliberately committed before transcript/provider work.
|
|
16419
16918
|
// If we died after that point, its outcome is unknown: recover the user's words
|
|
16420
16919
|
// and a review notice, never hand them to a model for a second execution.
|