openmausbot 0.1.82 → 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-BLLsDK2F.js → index-DSvqfBOf.js} +71 -71
- package/dist/assets/{index-5HVwp5m2.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 +1063 -493
- package/dist-server/mcp-gate.js +2 -1
- package/dist-server/openmausbot.js +187 -55
- package/dist-server/pair-cli.js +187 -55
- 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/gemini.js +11 -1
- 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 +534 -84
- package/dist-server/server/local-routing.js +10 -4
- 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/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 +6 -0
- 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);
|
|
@@ -676,6 +681,14 @@ function botAtThreadCapacity(botId) {
|
|
|
676
681
|
function hasDirectDispatch(botId) {
|
|
677
682
|
return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
|
|
678
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
|
+
}
|
|
679
692
|
function requestedTaskBot(botId, rawThreadId) {
|
|
680
693
|
const profile = store.bot(botId);
|
|
681
694
|
if (!profile)
|
|
@@ -1265,7 +1278,9 @@ if (browserCleanupReferencesReconciled)
|
|
|
1265
1278
|
* than the desktop window did. Stripped here rather than at each call site
|
|
1266
1279
|
* so a new broadcast cannot forget. */
|
|
1267
1280
|
let activeCoordinationForThread = (_threadId) => false;
|
|
1268
|
-
const wireTask = (
|
|
1281
|
+
const wireTask = (task) => activeCoordinationForThread(task.threadId) && !task.busy
|
|
1282
|
+
? { ...toWireTask(task), busy: true, activity: "working" }
|
|
1283
|
+
: toWireTask(task);
|
|
1269
1284
|
const wireBot = (bot) => {
|
|
1270
1285
|
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
|
|
1271
1286
|
// An elevated selection is inert until the desktop confirms its exact
|
|
@@ -1433,7 +1448,10 @@ async function botOverview(bot) {
|
|
|
1433
1448
|
* approval semantics require an implemented provider mapping. The trusted transition enforces
|
|
1434
1449
|
* this too, but no provider dispatch or later permission callback relies on
|
|
1435
1450
|
* persistence having been produced exclusively by that route. Delegation
|
|
1436
|
-
* 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. */
|
|
1437
1455
|
const approvalModeForTurn = (bot, peerInitiated = false) => {
|
|
1438
1456
|
const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
|
|
1439
1457
|
if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
|
|
@@ -1454,6 +1472,48 @@ function fullAccessForSource(botId, threadId) {
|
|
|
1454
1472
|
function peerReviewRequired(bot, threadId) {
|
|
1455
1473
|
return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
|
|
1456
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
|
+
}
|
|
1457
1517
|
/** Privileged approval-mode transitions are deliberately absent from the
|
|
1458
1518
|
* loopback HTTP authority model: a bot with shell access can curl that
|
|
1459
1519
|
* surface itself. Only Electron's private utility-process channel can deliver
|
|
@@ -2260,7 +2320,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
2260
2320
|
await tracked;
|
|
2261
2321
|
return { ok: result.outcome === "settled", text: result.stopReason || result.replyText || result.outcome || "The addressed agent could not run" };
|
|
2262
2322
|
},
|
|
2263
|
-
});
|
|
2323
|
+
}, Date.now, roomHandoffLimits(cfg));
|
|
2264
2324
|
activeCoordinationForThread = threadId => roomHandoffs.activeDirect(threadId);
|
|
2265
2325
|
function publicGroupState(group) {
|
|
2266
2326
|
return { ...group, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
|
|
@@ -3152,6 +3212,15 @@ const localVmLeases = new LocalVmLeasePool(30 * 60_000);
|
|
|
3152
3212
|
const localVmLifecycleBusy = new Set();
|
|
3153
3213
|
const localVmThreadTargets = new Map();
|
|
3154
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;
|
|
3155
3224
|
/** Local VM targets this process has seen ready or recreatable — at boot, in
|
|
3156
3225
|
* the inventory, or in a turn. Auto probes the container runtime for a VM
|
|
3157
3226
|
* only when one of these exists, so an ordinary Auto turn on a machine with
|
|
@@ -3577,6 +3646,10 @@ function localVmIdleFor(target) {
|
|
|
3577
3646
|
return idle;
|
|
3578
3647
|
}
|
|
3579
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);
|
|
3580
3653
|
const target = localVmThreadTargets.get(threadId);
|
|
3581
3654
|
if (!target)
|
|
3582
3655
|
return;
|
|
@@ -5007,6 +5080,17 @@ async function startTurn(botId, text, opts) {
|
|
|
5007
5080
|
// branch only — abandoned forks never reach the model
|
|
5008
5081
|
const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]);
|
|
5009
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
|
+
}
|
|
5010
5094
|
// A flat reply may deliberately point across a fork in the same thread.
|
|
5011
5095
|
// Resolve its quote from full storage, while the replay itself remains
|
|
5012
5096
|
// strictly limited to the selected branch below.
|
|
@@ -5017,7 +5101,8 @@ async function startTurn(botId, text, opts) {
|
|
|
5017
5101
|
.map((m) => ({
|
|
5018
5102
|
role: m.role === "user" ? "user" : "assistant",
|
|
5019
5103
|
text: m.roomRequest?.phase === "result" ? teammateReportContext(m.roomRequest.id, bot.id)
|
|
5020
|
-
: 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"),
|
|
5021
5106
|
}));
|
|
5022
5107
|
// After a rewind (edit / branch switch) the provider's native session
|
|
5023
5108
|
// still contains the abandoned branch: start a fresh session instead of
|
|
@@ -5215,6 +5300,79 @@ async function startTurn(botId, text, opts) {
|
|
|
5215
5300
|
let browserCapture = null;
|
|
5216
5301
|
let computerKind = null;
|
|
5217
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
|
+
};
|
|
5218
5376
|
// Explicit destinations are strict. In particular, Local VM must never
|
|
5219
5377
|
// fall through to host CUA and accidentally click on the user's Mac.
|
|
5220
5378
|
// The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
|
|
@@ -5229,6 +5387,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5229
5387
|
throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
|
|
5230
5388
|
}
|
|
5231
5389
|
const localVmTarget = localVmTargetForBot(bot.id);
|
|
5390
|
+
let lazyReadyVm = null;
|
|
5232
5391
|
if (!strict) {
|
|
5233
5392
|
// Nothing this process has ever seen for this target, and nobody is
|
|
5234
5393
|
// relying on an unattended run: do not pay for a runtime probe.
|
|
@@ -5239,43 +5398,50 @@ async function startTurn(botId, text, opts) {
|
|
|
5239
5398
|
return false;
|
|
5240
5399
|
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key))
|
|
5241
5400
|
return false;
|
|
5401
|
+
if (seen.ready && seen.runtime)
|
|
5402
|
+
lazyReadyVm = { runtime: seen.runtime };
|
|
5242
5403
|
}
|
|
5243
5404
|
try {
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
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;
|
|
5260
5435
|
}
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
//
|
|
5264
|
-
//
|
|
5265
|
-
//
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
// bot's name — live and in the settled transcript frame, which is
|
|
5271
|
-
// taken after the lease is already released. No owner means the
|
|
5272
|
-
// desktop is simply idle: that final frame is ours to keep.
|
|
5273
|
-
const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
|
|
5274
|
-
if (owner && owner.threadId !== threadId) {
|
|
5275
|
-
throw new Error("the Local VM moved on to another turn");
|
|
5276
|
-
}
|
|
5277
|
-
return containerComputerFrame(undefined, undefined, localVmTarget);
|
|
5278
|
-
};
|
|
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
|
+
});
|
|
5279
5445
|
return true;
|
|
5280
5446
|
}
|
|
5281
5447
|
catch (error) {
|
|
@@ -5295,7 +5461,11 @@ async function startTurn(botId, text, opts) {
|
|
|
5295
5461
|
hostPlatform: process.platform,
|
|
5296
5462
|
providerSupportsLocal: mountsLocalComputer,
|
|
5297
5463
|
})) {
|
|
5298
|
-
|
|
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");
|
|
5299
5469
|
}
|
|
5300
5470
|
const cua = readCuaConnection();
|
|
5301
5471
|
if (!cua)
|
|
@@ -5958,10 +6128,7 @@ routines = new RoutineManager({
|
|
|
5958
6128
|
hasPendingDelegations: (threadId) => pendingThreads().includes(threadId) ||
|
|
5959
6129
|
[...delegationWatch.values()].some((watch) => watch.sourceThreadId === threadId) ||
|
|
5960
6130
|
pendingDelegationWakes.has(threadId),
|
|
5961
|
-
botState:
|
|
5962
|
-
const bot = store.bot(botId);
|
|
5963
|
-
return !bot ? "missing" : bot.busy ? "busy" : "ready";
|
|
5964
|
-
},
|
|
6131
|
+
botState: unattendedDispatchState,
|
|
5965
6132
|
goalState: (groupId, coordinatorBotId) => {
|
|
5966
6133
|
const group = store.group(groupId);
|
|
5967
6134
|
const coordinator = store.bot(coordinatorBotId);
|
|
@@ -6342,12 +6509,6 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
6342
6509
|
const acknowledged = await browserCleanup.ensure(committedCleanup);
|
|
6343
6510
|
requireBrowserCleanupAcknowledged(acknowledged, `Browser data for ${bot.name}`);
|
|
6344
6511
|
}
|
|
6345
|
-
for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
|
|
6346
|
-
try {
|
|
6347
|
-
unlinkSync(join(dir, `${bot.threadId}.ndjson`));
|
|
6348
|
-
}
|
|
6349
|
-
catch { }
|
|
6350
|
-
}
|
|
6351
6512
|
return deletionResponse(200, { ok: true });
|
|
6352
6513
|
}
|
|
6353
6514
|
finally {
|
|
@@ -6656,10 +6817,7 @@ function resolveAndSendProfile(res, args) {
|
|
|
6656
6817
|
// ordered behind a busy MAUS and gives webhook runs the same durable receipts.
|
|
6657
6818
|
const webhooks = new WebhookManager({
|
|
6658
6819
|
emit: broadcast,
|
|
6659
|
-
botState:
|
|
6660
|
-
const bot = store.bot(botId);
|
|
6661
|
-
return !bot ? "missing" : bot.busy ? "busy" : "ready";
|
|
6662
|
-
},
|
|
6820
|
+
botState: unattendedDispatchState,
|
|
6663
6821
|
enqueue: (input) => routines.enqueueWebhook(input),
|
|
6664
6822
|
findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
|
|
6665
6823
|
cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
|
|
@@ -6794,7 +6952,11 @@ setupRetry = 0) {
|
|
|
6794
6952
|
}
|
|
6795
6953
|
revokeInternalCapabilitiesForThread(threadId);
|
|
6796
6954
|
spoken.add(botId);
|
|
6797
|
-
|
|
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);
|
|
6798
6960
|
const preparedSelection = { ...bot.modelSelection };
|
|
6799
6961
|
const preparedComposio = bot.composio;
|
|
6800
6962
|
const instance = turnInstance(bot);
|
|
@@ -6922,7 +7084,7 @@ setupRetry = 0) {
|
|
|
6922
7084
|
if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
|
|
6923
7085
|
return false;
|
|
6924
7086
|
const setupChanged = turnInstance(readyBot) !== instance ||
|
|
6925
|
-
|
|
7087
|
+
roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
|
|
6926
7088
|
readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
|
|
6927
7089
|
readyBot.modelSelection.model !== preparedSelection.model ||
|
|
6928
7090
|
readyBot.modelSelection.effort !== preparedSelection.effort ||
|
|
@@ -7295,7 +7457,7 @@ setupRetry = 0) {
|
|
|
7295
7457
|
text,
|
|
7296
7458
|
refreshSystemPrompt: true,
|
|
7297
7459
|
images: turnImages,
|
|
7298
|
-
approvalMode:
|
|
7460
|
+
approvalMode: roomTurnApprovalMode(readyBot, orchestration),
|
|
7299
7461
|
system: roomSystem.text,
|
|
7300
7462
|
systemStable: roomSystem.stable,
|
|
7301
7463
|
systemVolatile: roomSystem.volatile,
|
|
@@ -10408,19 +10570,27 @@ const handleRequest = async (req, res) => {
|
|
|
10408
10570
|
if (method === "POST" && path === "/api/internal/ask-bot") {
|
|
10409
10571
|
const body = await readInternalBody();
|
|
10410
10572
|
const fromBotId = internalSender.id;
|
|
10411
|
-
const
|
|
10573
|
+
const toBotRef = String(body.toBotId ?? "");
|
|
10412
10574
|
const message = String(body.message ?? "").trim();
|
|
10413
10575
|
if (body.depth !== undefined &&
|
|
10414
10576
|
(!Number.isInteger(body.depth) || body.depth < 0 || body.depth !== internalCapability.depth)) {
|
|
10415
10577
|
return json(res, 403, { error: "the recursion depth does not match this turn" });
|
|
10416
10578
|
}
|
|
10417
10579
|
const depth = internalCapability.depth;
|
|
10418
|
-
if (!
|
|
10580
|
+
if (!toBotRef || !message)
|
|
10419
10581
|
return json(res, 400, { error: "toBotId and message required" });
|
|
10420
|
-
if (
|
|
10582
|
+
if (toBotRef === fromBotId)
|
|
10421
10583
|
return json(res, 400, { error: "a bot cannot message itself" });
|
|
10422
10584
|
if (depth >= MAX_COMMS_DEPTH)
|
|
10423
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;
|
|
10424
10594
|
const target = store.bot(toBotId);
|
|
10425
10595
|
if (!target)
|
|
10426
10596
|
return json(res, 404, { error: "no such bot" });
|
|
@@ -10623,7 +10793,7 @@ const handleRequest = async (req, res) => {
|
|
|
10623
10793
|
}
|
|
10624
10794
|
if (method === "POST" && path === "/api/internal/delegate-bot") {
|
|
10625
10795
|
const body = await readInternalBody();
|
|
10626
|
-
const
|
|
10796
|
+
const toBotRef = String(body.toBotId ?? "");
|
|
10627
10797
|
const message = String(body.message ?? "").trim();
|
|
10628
10798
|
const reason = typeof body.reason === "string" && body.reason.trim() ? body.reason.trim() : undefined;
|
|
10629
10799
|
if (body.depth !== undefined &&
|
|
@@ -10631,9 +10801,13 @@ const handleRequest = async (req, res) => {
|
|
|
10631
10801
|
return json(res, 403, { error: "the recursion depth does not match this turn" });
|
|
10632
10802
|
}
|
|
10633
10803
|
const depth = internalCapability.depth;
|
|
10634
|
-
if (!
|
|
10804
|
+
if (!toBotRef || !message)
|
|
10635
10805
|
return json(res, 400, { error: "toBotId and message required" });
|
|
10636
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;
|
|
10637
10811
|
const target = store.bot(toBotId);
|
|
10638
10812
|
if (!target)
|
|
10639
10813
|
return json(res, 404, { error: "no such bot" });
|
|
@@ -10704,11 +10878,35 @@ const handleRequest = async (req, res) => {
|
|
|
10704
10878
|
const destination = groupId ? store.group(groupId) : undefined;
|
|
10705
10879
|
if (groupId && !destination)
|
|
10706
10880
|
return json(res, 404, { error: "No such room; use list_room_targets." });
|
|
10707
|
-
|
|
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,
|
|
10708
10896
|
threadId: destination ? destination.id === source?.id ? address.threadId : destination.threadId : store.bot(botId)?.threadId ?? "", botId,
|
|
10709
10897
|
}));
|
|
10710
10898
|
for (const target of targets) {
|
|
10711
|
-
|
|
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);
|
|
10712
10910
|
if (eligibility)
|
|
10713
10911
|
return json(res, 403, { error: eligibility });
|
|
10714
10912
|
}
|
|
@@ -10744,6 +10942,9 @@ const handleRequest = async (req, res) => {
|
|
|
10744
10942
|
target.threadId = resolved.task.threadId;
|
|
10745
10943
|
if (resolved.created)
|
|
10746
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
|
+
}
|
|
10747
10948
|
}
|
|
10748
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 ?? "");
|
|
10749
10950
|
// A re-dispatched request_key is answered by the request it
|
|
@@ -11012,6 +11213,8 @@ const handleRequest = async (req, res) => {
|
|
|
11012
11213
|
const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
11013
11214
|
if (!task)
|
|
11014
11215
|
return json(res, 500, { error: "couldn't create that thread" });
|
|
11216
|
+
if (delegatedFullAccess(from, fromThreadId, target))
|
|
11217
|
+
grantDelegatedFullAccess(from, target, task.threadId);
|
|
11015
11218
|
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
|
|
11016
11219
|
if (queued.result !== "ok" || !queued.id) {
|
|
11017
11220
|
// nothing will ever run there: take the row back before the person
|
|
@@ -11258,6 +11461,48 @@ const handleRequest = async (req, res) => {
|
|
|
11258
11461
|
return json(res, 404, { error: "no such bot" });
|
|
11259
11462
|
if (method === "GET") {
|
|
11260
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
|
+
}
|
|
11261
11506
|
const computer = turnComputerResources.get(internalCapability.threadId);
|
|
11262
11507
|
if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
|
|
11263
11508
|
!claimTurnResource(computer.owner, computer.resource)) {
|
|
@@ -12567,14 +12812,6 @@ const handleRequest = async (req, res) => {
|
|
|
12567
12812
|
routines.disableForGroup(group.id);
|
|
12568
12813
|
store.deleteGroup(group.id);
|
|
12569
12814
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
12570
|
-
for (const threadId of threadIds) {
|
|
12571
|
-
for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
|
|
12572
|
-
try {
|
|
12573
|
-
unlinkSync(join(dir, `${threadId}.ndjson`));
|
|
12574
|
-
}
|
|
12575
|
-
catch { }
|
|
12576
|
-
}
|
|
12577
|
-
}
|
|
12578
12815
|
return json(res, 200, { ok: true });
|
|
12579
12816
|
}
|
|
12580
12817
|
m = path.match(/^\/api\/groups\/([\w-]+)\/messages$/);
|
|
@@ -12682,6 +12919,112 @@ const handleRequest = async (req, res) => {
|
|
|
12682
12919
|
}
|
|
12683
12920
|
return json(res, 200, { ok: true });
|
|
12684
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
|
+
}
|
|
12685
13028
|
m = path.match(/^\/api\/groups\/([\w-]+)\/interrupt$/);
|
|
12686
13029
|
if (m && method === "POST") {
|
|
12687
13030
|
const group = store.group(m[1]);
|
|
@@ -14055,7 +14398,7 @@ const handleRequest = async (req, res) => {
|
|
|
14055
14398
|
// existing server-side queue records it atomically for the next turn.
|
|
14056
14399
|
if (currentAtStart.busy) {
|
|
14057
14400
|
const instance = runningTurnInstance(currentAtStart, threadId);
|
|
14058
|
-
let steered =
|
|
14401
|
+
let steered = "refused";
|
|
14059
14402
|
// A live text steer has no image side channel. Keep an attachment
|
|
14060
14403
|
// message intact for the next ordinary turn, where central image
|
|
14061
14404
|
// admission can hand it to the provider natively.
|
|
@@ -14063,7 +14406,7 @@ const handleRequest = async (req, res) => {
|
|
|
14063
14406
|
if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
|
|
14064
14407
|
steered = await instance.adapter
|
|
14065
14408
|
.steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
|
|
14066
|
-
.catch(() =>
|
|
14409
|
+
.catch(() => "indeterminate");
|
|
14067
14410
|
}
|
|
14068
14411
|
// steer() is awaited adapter work. The turn can settle, the task can
|
|
14069
14412
|
// switch, or the whole bot can be deleted before its acknowledgement
|
|
@@ -14077,10 +14420,15 @@ const handleRequest = async (req, res) => {
|
|
|
14077
14420
|
if (!store.taskByThread(bot.id, threadId)) {
|
|
14078
14421
|
throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
|
|
14079
14422
|
}
|
|
14080
|
-
|
|
14081
|
-
|
|
14423
|
+
const delivered = steered !== "refused";
|
|
14424
|
+
if (delivered) {
|
|
14425
|
+
if (steered === "steered" && !current.busy) {
|
|
14082
14426
|
throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
|
|
14083
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.
|
|
14084
14432
|
// A person steering a webhook turn is present, and auto mode may
|
|
14085
14433
|
// follow them again. But this route is also reachable from the
|
|
14086
14434
|
// bot's own shell on a headless server (loopback is the owner
|
|
@@ -14126,6 +14474,77 @@ const handleRequest = async (req, res) => {
|
|
|
14126
14474
|
}
|
|
14127
14475
|
return json(res, 200, { ok: true });
|
|
14128
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
|
+
}
|
|
14129
14548
|
// edit a user message → fork the conversation there and rerun the turn.
|
|
14130
14549
|
// Rewinding a live thread is refused, exactly like switching versions
|
|
14131
14550
|
// below: interrupting mid-flight and branching under the dying turn is
|
|
@@ -16464,6 +16883,37 @@ try {
|
|
|
16464
16883
|
catch (error) {
|
|
16465
16884
|
console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
16466
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();
|
|
16467
16917
|
// A dispatch claim is deliberately committed before transcript/provider work.
|
|
16468
16918
|
// If we died after that point, its outcome is unknown: recover the user's words
|
|
16469
16919
|
// and a review notice, never hand them to a model for a second execution.
|