diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index e569d8a0f..6fb890843 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -106,6 +106,25 @@ describe('per-installation agent settings', () => { expect(after.body.runtimeControls.effort).toBe('xhigh'); }); + it('reports the process from the agent listener, not the computer heartbeat', async () => { + await Machine.create({ ownerUserId: owner._id, machineId: 'machine-a', name: 'Test computer', lastSeenAt: null }); + await User.create({ + username: 'settings-runtime-bot', email: 'settings-runtime-bot@test.com', password: 'password123', + isBot: true, botMetadata: { agentName: 'settings-agent', instanceId: 'same', machineId: 'machine-a' }, + agentRuntimeTokens: [{ tokenHash: 'listener-hash', lastUsedAt: new Date(Date.now() - 10_000) }], + }); + const url = '/api/registry/agents/settings-agent/manage-settings?instanceId=same'; + const polling = await request(app).get(url).set('Authorization', `Bearer ${token(owner)}`); + expect(polling.status).toBe(200); + expect(polling.body.processConnected).toBe(true); + + await Machine.updateOne({ machineId: 'machine-a' }, { $set: { lastSeenAt: new Date() } }); + await User.updateOne({ username: 'settings-runtime-bot' }, + { $set: { 'agentRuntimeTokens.0.lastUsedAt': new Date(Date.now() - 10 * 60_000) } }); + const silent = await request(app).get(url).set('Authorization', `Bearer ${token(owner)}`); + expect(silent.body.processConnected).toBe(false); + }); + it('does not present a standalone CLI preference as a running effort', async () => { await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { $set: { 'config.environment': { model: 'opus', effort: 'max' } }, diff --git a/backend/__tests__/service/native-agent-status.test.js b/backend/__tests__/service/native-agent-status.test.js new file mode 100644 index 000000000..479946c67 --- /dev/null +++ b/backend/__tests__/service/native-agent-status.test.js @@ -0,0 +1,76 @@ +/** + * commonly_agent_status is how Scout answers "is my agent running?". On + * 2026-10-28 it missed a listener that was polling: the seat was installed in + * another pod, and its key lived in the credential ledger, not on the bot User + * (D172). Real Mongo, so the stores are the ones runtime auth writes. + */ +const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); + +jest.mock('axios', () => ({ __esModule: true, default: { post: jest.fn() } })); +jest.mock('../../services/agentComputerBroker', () => ({ + agentComputerBroker: {}, + agentComputerEnabled: false, +})); + +const mongoose = require('mongoose'); +const User = require('../../models/User'); +const AgentCredential = require('../../models/AgentCredential'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const { dispatchTool } = require('../../services/nativeRuntimeService'); + +jest.setTimeout(60000); + +describe('commonly_agent_status', () => { + const scoutPod = new mongoose.Types.ObjectId(); + const otherPod = new mongoose.Types.ObjectId(); + let owner; + let bot; + + beforeAll(async () => { + await setupMongoDb(); + owner = await User.create({ username: 'status-owner', email: 'status-owner@test.com', password: 'password123' }); + bot = await User.create({ + username: 'claude-code-status-seat', email: 'status-seat@test.com', password: 'password123', isBot: true, + botMetadata: { agentName: 'claude-code', instanceId: 'status-seat' }, + }); + }); + + afterAll(async () => closeMongoDb()); + + beforeEach(async () => { + await AgentInstallation.deleteMany({ agentName: 'claude-code' }); + await AgentCredential.deleteMany({ agentUserId: bot._id }); + await AgentInstallation.create({ + agentName: 'claude-code', instanceId: 'status-seat', podId: otherPod, version: '1', installedBy: owner._id, + config: new Map([['runtime', { runtimeType: 'wrapper' }]]), + }); + }); + + const ask = (requesterId) => dispatchTool('commonly_agent_status', {}, { + podId: String(scoutPod), agentName: 'scout', instanceId: 'default', displayName: 'Scout', + installationConfig: {}, runId: 'run-status', computerRequesterId: requesterId, + }); + + it('reports the asker\'s agent from another pod, live from its ledger key', async () => { + await AgentCredential.create({ + tokenHash: 'status-ledger-hash', kind: 'runtime', ownerUserId: owner._id, agentUserId: bot._id, + lastUsedAt: new Date(Date.now() - 5000), + }); + const result = await ask(String(owner._id)); + expect(result.content.agents).toEqual([expect.objectContaining({ + agentName: 'claude-code', state: 'active', inThisPod: false, + })]); + }); + + it('reads a key kept on the installation', async () => { + await AgentInstallation.updateOne({ agentName: 'claude-code' }, + { $set: { runtimeTokens: [{ tokenHash: 'legacy-hash', lastUsedAt: new Date(Date.now() - 5000) }] } }); + const result = await ask(String(owner._id)); + expect(result.content.agents[0].state).toBe('active'); + }); + + it('does not list another person\'s agents from other pods', async () => { + const result = await ask(String(new mongoose.Types.ObjectId())); + expect(result.content.agents).toEqual([]); + }); +}); diff --git a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index dfe799aed..e00a982c8 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -290,3 +290,27 @@ describe('Scout\'s welcome', () => { expect(SCOUT_WELCOME).not.toMatch(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uff00-\uffef]/); }); }); + +describe('Scout running-state claims', () => { + const prompt = scoutApp.systemPrompt; + + // A BYO seat's intro is posted at install and says nothing is running it. + // Scout read that message hours later and told a connected user their agent + // was not running (D172, 2026-10-28). + test('checks live status before any running-or-not claim', () => { + expect(prompt).toContain('Call it before you say anything about whether an agent is running'); + expect(prompt).toContain('Never judge that from earlier messages'); + expect(prompt).toContain('unless commonly_agent_status said so in this turn'); + }); + + // idle covers five minutes to a day, so a listener that stopped half an hour + // ago reads idle, not stale. Scout must give restart advice for it (rhea on #2969). + test('names every status bucket the tool returns, and gives restart advice for idle', () => { + for (const bucket of ['active:', 'idle:', 'stale:', 'never-connected', 'ready']) { + expect(prompt).toContain(bucket); + } + expect(prompt).toContain('idle and stale both mean its listener stopped'); + expect(prompt).toContain('within the last 5 minutes'); + expect(prompt).not.toContain('nothing needs fixing'); + }); +}); diff --git a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js index 330de9ddd..bf071d2e3 100644 --- a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js +++ b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js @@ -225,6 +225,25 @@ describe('pod agents lastActiveAt derivation (#915)', () => { expect(res.body.agents[0].connection.lastSeenAt).toEqual(expect.any(String)); }); + it('shows a daemon-bound seat stale when its listener stopped, even with a live machine', async () => { + AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({ + agentName: 'local', config: new Map([['runtime', { runtimeType: 'wrapper' }]]), + })]); + User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{ + _id: 'bot-1', username: 'local-default', botMetadata: { machineId: 'machine-1' }, agentRuntimeTokens: [], + }]) }) }); + AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([ + { kind: 'runtime', agentUserId: 'bot-1', status: 'active', lastUsedAt: new Date(Date.now() - 10 * 60 * 1000) }, + { kind: 'daemon', machineId: 'machine-1', status: 'active' }, + ]) }) }); + Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{ + machineId: 'machine-1', lastSeenAt: new Date(), + }]) }) }); + const res = await request(app).get(`/api/registry/pods/${POD_ID}/agents`); + expect(res.status).toBe(200); + expect(res.body.agents[0].connection.state).toBe('stale'); + }); + it('projects a daemon-bound seat as a wrapper even when its install label is not wrapper', async () => { AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({ agentName: 'local', config: new Map([['runtime', { runtimeType: 'claude-code' }]]), @@ -232,9 +251,9 @@ describe('pod agents lastActiveAt derivation (#915)', () => { User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{ _id: 'bot-1', username: 'local-default', botMetadata: { machineId: 'machine-1' }, agentRuntimeTokens: [], }]) }) }); - // The runtime key is aged past the 5-minute window, so only the live machine can make this seat connected. + // The agent's own listener polled seconds ago. The live machine alone would not make it connected. AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([ - { kind: 'runtime', agentUserId: 'bot-1', status: 'active', lastUsedAt: new Date(Date.now() - 10 * 60 * 1000) }, + { kind: 'runtime', agentUserId: 'bot-1', status: 'active', lastUsedAt: new Date() }, { kind: 'daemon', machineId: 'machine-1', status: 'active' }, ]) }) }); Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{ diff --git a/backend/__tests__/unit/services/agentConnectionStateService.test.js b/backend/__tests__/unit/services/agentConnectionStateService.test.js index fa1918b1d..e8c031d8c 100644 --- a/backend/__tests__/unit/services/agentConnectionStateService.test.js +++ b/backend/__tests__/unit/services/agentConnectionStateService.test.js @@ -21,10 +21,22 @@ describe('agent connection verdict', () => { ], now }).state).toBe('stale'); }); - it('uses machine heartbeat for a wrapper', () => { + it('calls a polling listener on a computer connected while the daemon is silent', () => { + expect(deriveAgentConnection({ runtimeType: 'wrapper', credentials: [ + { status: 'active', lastUsedAt: new Date(now - 10_000) }, + ], machineCredentials: [{ status: 'active' }], machineLastSeenAt: null, now }).state).toBe('connected'); + }); + + it('does not let a live daemon stand in for a listener that stopped polling', () => { + expect(deriveAgentConnection({ runtimeType: 'wrapper', credentials: [ + { status: 'active', lastUsedAt: seen(10) }, + ], machineCredentials: [{ status: 'active' }], machineLastSeenAt: seen(0), now }).state).toBe('stale'); + }); + + it('never calls a computer agent connected from the heartbeat alone', () => { expect(deriveAgentConnection({ runtimeType: 'wrapper', credentials: [ { status: 'active' }, - ], machineCredentials: [{ status: 'active' }], machineLastSeenAt: seen(2), now }).state).toBe('stale'); + ], machineCredentials: [{ status: 'active' }], machineLastSeenAt: seen(0), now }).state).toBe('never'); }); it('does not let a live machine mask the agent runtime key being revoked', () => { diff --git a/backend/__tests__/unit/services/agentStateService.activity.test.js b/backend/__tests__/unit/services/agentStateService.activity.test.js index 0aca1dc1e..efc5611ed 100644 --- a/backend/__tests__/unit/services/agentStateService.activity.test.js +++ b/backend/__tests__/unit/services/agentStateService.activity.test.js @@ -23,6 +23,10 @@ describe('deriveActivityBucket', () => { expect(deriveActivityBucket(minutesAgo(3), 'webhook')).toBe('active'); }); + test('seen seven minutes ago = idle, matching the five-minute connection window', () => { + expect(deriveActivityBucket(minutesAgo(7), 'webhook')).toBe('idle'); + }); + test('seen within a day = idle', () => { expect(deriveActivityBucket(minutesAgo(120), 'webhook')).toBe('idle'); }); diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index 129200945..e8975f7ba 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -253,12 +253,23 @@ export const scoutApp = { + 'and an OS where it can be connected. Do not ask whether to: the card ' + 'is the question, and they answer it by approving. Do not write the ' + 'Terminal steps in chat instead of the card.\n' - + '- commonly_agent_status tells you every agent\'s live state. Use it ' - + 'whenever the user asks whether an agent is connected or why it is ' - + 'silent: never-connected means the connect-page step was never finished ' - + '(point them there); stale means it stopped checking in (restart ' - + '`commonly agent run `); ready means a native agent with nothing ' - + 'to connect.\n' + + '- commonly_agent_status tells you every agent\'s live state. Call it ' + + 'before you say anything about whether an agent is running, connected ' + + 'or silent, every time. Never judge that from earlier messages: an ' + + 'agent\'s own intro says nothing is running it because it was posted ' + + 'at install, before anything started it. Report its states as recent ' + + 'check-ins, with the lastActiveAt time. active: it checked in within the ' + + 'last 5 minutes, which is not the same as working on something right ' + + 'now. idle: its last check-in was between 5 minutes and a day ago. stale: ' + + 'more than a day ago. For an agent the user runs on their own machine, ' + + 'idle and stale both mean its listener stopped: tell them to restart it ' + + 'with `commonly agent run `, or to check that the computer it runs ' + + 'on is awake and online. For a hosted agent, idle and stale only mean no ' + + 'turn started lately; they say nothing about whether its last run ' + + 'succeeded, so give the last activity time and do not call it healthy. ' + + 'never-connected means ' + + 'the connect-page step was never finished (point them there); ready ' + + 'means a native agent with nothing to connect.\n' + '\n' + 'HOW YOU ANSWER:\n' + '- Answer the literal question first, in your first sentence, with the ' @@ -309,8 +320,8 @@ export const scoutApp = { + '[["LIN-12 Fix login", "In progress"]])]). Every row needs one cell per Col. ' + 'Show at most 10 rows and say how many more there are in a Text line. Keep each ' + 'cell under 80 characters. Copy the catalogue fields exactly.', - 'Never claim the user\'s own agent is connected or working — the ' - + 'connect page verifies that, not you. Point them there instead. And ' + 'Never claim the user\'s own agent is connected or working, or that it ' + + 'is not, unless commonly_agent_status said so in this turn. And ' + 'remember you are the product\'s first impression: an invented product ' + 'fact from you is worse than one from anyone else.', ), diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index 4d8309ce0..5c1fb2cbe 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -5,6 +5,8 @@ import { hostedCapsForOwner, userRunCeilingForOwner } from '../../services/hoste import { projectSeatEnvironments, seatEnvironmentKey } from '../../services/seatEnvironmentProjection'; const { ownKeyCapability } = require('../../services/agentProviderKey'); const Machine = require('../../models/Machine'); +const AgentCredential = require('../../models/AgentCredential'); +const { deriveAgentConnection } = require('../../services/agentConnectionStateService'); const AgentRuntimeObservation = require('../../models/AgentRuntimeObservation'); const express = require('express'); const auth = require('../../middleware/auth'); @@ -89,13 +91,19 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: isBot: true, 'botMetadata.agentName': String(req.params.name).toLowerCase(), 'botMetadata.instanceId': normalizeInstanceId(req.query.instanceId), - }).select('botMetadata.machineId').lean(); + }).select('botMetadata.machineId agentRuntimeTokens.tokenHash agentRuntimeTokens.lastUsedAt ' + + 'agentRuntimeTokens.expiresAt').lean(); const machine = binding?.botMetadata?.machineId ? await Machine.findOne({ machineId: binding.botMetadata.machineId, ownerUserId: quotaOwner, - }).select('runtimeInventory inventoryAt lastSeenAt agentStates').lean() : null; + }).select('runtimeInventory inventoryAt lastSeenAt').lean() : null; const observation = binding && machine ? await AgentRuntimeObservation.findOne({ agentUserId: binding._id, machineId: binding.botMetadata.machineId, }).select('connectedAt lastSeenAt lastSpawn lastSpawnOutcome turnRunning lastTurnAt -_id').lean() : null; + // The process line follows the agent's own credential use, as the roster + // does. The machine heartbeat says the computer is online, not that this + // agent's listener is. + const credentials = binding && machine ? await AgentCredential.find({ kind: 'runtime', agentUserId: binding._id }) + .select('tokenHash status lastUsedAt expiresAt').lean() : []; const runtimeManaged = Boolean(binding?.botMetadata?.machineId); if (runtimeManaged && !runtimeControls.runtimeType) runtimeControls.runtimeType = 'wrapper'; const runtimeSavedControls = { ...runtimeControls }; @@ -120,9 +128,8 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: runtimeSavedControls, ownKeyCapability: runtimeManaged ? ownKeyCapability(runtime, environment) : { available: false, provider: null }, runtimeObservation: observation ? { ...observation, confirmed: null } : null, - processConnected: machine ? Boolean(machine.lastSeenAt && Date.now() - new Date(machine.lastSeenAt).getTime() < 90_000 - && machine.agentStates?.some((entry: any) => entry.agentName === String(req.params.name).toLowerCase() - && entry.instanceId === normalizeInstanceId(req.query.instanceId) && entry.state === 'running')) : null, + processConnected: machine ? deriveAgentConnection({ runtimeType: 'wrapper', credentials, + embeddedTokens: binding?.agentRuntimeTokens || [] }).state === 'connected' : null, runtimeInventory: machine?.runtimeInventory || [], runtimeInventoryAt: machine?.inventoryAt || null, machineLastSeenAt: machine?.lastSeenAt || null, diff --git a/backend/services/agentConnectionStateService.ts b/backend/services/agentConnectionStateService.ts index b391fe712..348116acc 100644 --- a/backend/services/agentConnectionStateService.ts +++ b/backend/services/agentConnectionStateService.ts @@ -6,13 +6,14 @@ export const MACHINE_STALE_MS = 90 * 1000; type Credential = { tokenHash?: string; kind?: string; status?: string; lastUsedAt?: Date | string | null; expiresAt?: Date | string | null }; export const deriveAgentConnection = ({ - runtimeType, credentials, embeddedTokens = [], machineLastSeenAt = null, now = Date.now(), + runtimeType, credentials, embeddedTokens = [], now = Date.now(), machineCredentials = [], }: { runtimeType?: string; credentials: Credential[]; machineCredentials?: Credential[]; embeddedTokens?: Credential[]; + // Accepted for callers; the computer row reads it, the agent verdict does not. machineLastSeenAt?: Date | string | null; now?: number; }) => { @@ -44,16 +45,18 @@ export const deriveAgentConnection = ({ if (isMachine && !active.length && !hasEmbedded) { return { state: 'never', lastSeenAt: null }; } - const dates = isMachine - ? [machineLastSeenAt] - : [...active, ...usableEmbedded].map((credential) => credential.lastUsedAt); + // The agent's own credential use decides its state, on a computer or not. + // The machine heartbeat belongs to the computer row: a live daemon in front + // of a dead listener is not a connected agent, and a polling listener + // behind a silent daemon is not a stale one. + const dates = [...active, ...usableEmbedded].map((credential) => credential.lastUsedAt); const lastSeen = dates.reduce((latest, value) => { const time = value ? new Date(value).getTime() : 0; return Number.isFinite(time) ? Math.max(latest, time) : latest; }, 0); if (!lastSeen) return { state: 'never', lastSeenAt: null }; return { - state: now - lastSeen > (isMachine ? MACHINE_STALE_MS : BYO_STALE_MS) ? 'stale' : 'connected', + state: now - lastSeen > BYO_STALE_MS ? 'stale' : 'connected', lastSeenAt: new Date(lastSeen).toISOString(), }; }; diff --git a/backend/services/agentStateService.ts b/backend/services/agentStateService.ts index 87c49d983..4548d8f29 100644 --- a/backend/services/agentStateService.ts +++ b/backend/services/agentStateService.ts @@ -26,6 +26,7 @@ * derives; the seam is what makes the honesty rules testable without a DB. */ import { hostedRunsNative } from './hostedEngine'; +import { BYO_STALE_MS } from './agentConnectionStateService'; export const AGENT_LISTENING_STALE_MS = 5 * 60 * 1000; @@ -153,7 +154,10 @@ export function deriveAgentState( // Never add a "new" service without reading the path first; never name two // exports the same thing because they feel similar. -const ACTIVE_WINDOW_MS = 10 * 60 * 1000; // active within 10 minutes +// The same five minutes as the roster and Settings connection verdict +// (agentConnectionStateService), so Scout and the page never disagree about +// an agent that checked in seven minutes ago. +const ACTIVE_WINDOW_MS = BYO_STALE_MS; const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // stale after a silent day export type AgentActivityBucket = 'active' | 'idle' | 'stale' | 'ready' | 'never-connected'; @@ -162,6 +166,7 @@ interface ActivityInstallationLike { agentName: string; instanceId?: string; config?: { runtime?: { runtimeType?: string } }; + runtimeTokens?: Array<{ lastUsedAt?: Date | null }>; } /** @@ -252,19 +257,42 @@ export const collectPodAgentActivity = async ( } const userRows = usernameByKey.size > 0 ? await User.find({ username: { $in: Array.from(usernameByKey.values()) } }) - .select('username agentRuntimeTokens.lastUsedAt') + .select('_id username agentRuntimeTokens.lastUsedAt') .lean() : []; - const userByUsername = new Map }>( + const userByUsername = new Map }>( (userRows as Array<{ username: string }>).map((u) => [u.username, u as never]), ); + // A seat's key can also live in the credential ledger (connect page, + // daemon mints, spawn children) or on a legacy installation. Runtime auth + // stamps lastUsedAt on whichever store authenticated the call, so the + // status read has to take all three. + // A failed ledger read throws rather than reading as "never connected": + // the status tool turns it into a failed call, and the roster route keeps + // its advisory fallback. + const credentialTimeByUser = new Map(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const AgentCredential = require('../models/AgentCredential'); + const agentUserIds = (userRows as Array<{ _id?: unknown }>).map((u) => u._id).filter(Boolean); + const credentials = agentUserIds.length > 0 + ? await AgentCredential.find({ kind: 'runtime', agentUserId: { $in: agentUserIds } }) + .select('agentUserId lastUsedAt') + .lean() + : []; + for (const credential of credentials as Array<{ agentUserId?: unknown; lastUsedAt?: Date | null }>) { + const time = credential.lastUsedAt ? new Date(credential.lastUsedAt).getTime() : 0; + const id = String(credential.agentUserId); + if (time > (credentialTimeByUser.get(id) || 0)) credentialTimeByUser.set(id, time); + } + for (const i of installations) { const k = key(i.agentName, i.instanceId); const user = userByUsername.get(usernameByKey.get(k) || ''); - const tokenTimes = (user?.agentRuntimeTokens || []) + const tokenTimes = [...(user?.agentRuntimeTokens || []), ...(i.runtimeTokens || [])] .map((tok) => (tok?.lastUsedAt ? new Date(tok.lastUsedAt).getTime() : 0)); - const candidates = [heartbeatMap.get(k), runMap.get(k), ...tokenTimes] + const credentialTime = user?._id ? credentialTimeByUser.get(String(user._id)) : undefined; + const candidates = [heartbeatMap.get(k), runMap.get(k), ...tokenTimes, credentialTime] .map((v) => (v ? new Date(v as Date).getTime() : 0)) .filter((v) => Number.isFinite(v) && v > 0); result.set(k, candidates.length > 0 ? new Date(Math.max(...candidates)) : null); diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index f118a431b..d43af67ba 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -382,9 +382,10 @@ export const TOOLS = [ function: { name: 'commonly_agent_status', description: - 'Check the connection status of every agent installed in this pod. ' + 'Check the connection status of every agent installed in this pod, and of ' + + 'the asker\'s own agents in other pods (inThisPod: false). ' + 'Returns each agent\'s runtime kind and state: active (seen in the last ' - + '10 minutes), idle (last day), stale (silent for over a day), ' + + '5 minutes), idle (last day), stale (silent for over a day), ' + 'never-connected (a local/BYO seat whose runtime has never ' + 'authenticated — the user still needs to link it from the connect ' + 'page), or ready (a native agent; it runs when addressed and has no ' @@ -1191,12 +1192,39 @@ export async function dispatchTool( // Raft-comparison P4 rule: never fork status vocabulary per surface. const { AgentInstallation } = require('../models/AgentRegistry'); const { collectPodAgentActivity, deriveActivityBucket } = require('./agentStateService'); - const installations = await AgentInstallation.find({ - podId: ctx.podId, + const mongoose = require('mongoose'); + // This pod's agents, plus the ones the person asking installed in + // other pods: someone who connected an agent from another pod still + // asks Scout here whether it is running. Only their own installs, and + // only name and state, never the other pod. + const requesterId = ctx.computerRequesterId; + const rows = await AgentInstallation.find({ status: 'active', - }).select('agentName instanceId displayName config').lean(); - const activity = await collectPodAgentActivity(String(ctx.podId), installations); - const agents = (installations as Array>).map((i) => { + ...(requesterId && mongoose.Types.ObjectId.isValid(requesterId) + ? { $or: [{ podId: ctx.podId }, { installedBy: requesterId }] } + : { podId: ctx.podId }), + }).select('agentName instanceId displayName config podId runtimeTokens.lastUsedAt').limit(100).lean(); + const seen = new Set(); + const installations = (rows as Array>) + .sort((a, b) => Number(String(b.podId) === String(ctx.podId)) - Number(String(a.podId) === String(ctx.podId))) + .filter((i) => { + const key = `${i.agentName}:${i.instanceId || 'default'}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const byPod = new Map>>(); + for (const i of installations) { + const podKey = String(i.podId); + byPod.set(podKey, [...(byPod.get(podKey) || []), i]); + } + const activity = new Map(); + for (const [podKey, group] of byPod) { + // eslint-disable-next-line no-await-in-loop + const podActivity = await collectPodAgentActivity(podKey, group as never); + for (const [key, value] of podActivity) activity.set(key, value); + } + const agents = installations.map((i) => { const key = `${i.agentName}:${i.instanceId || 'default'}`; const lastActiveAt = activity.get(key) || null; const runtimeType = String(i.config?.runtime?.runtimeType || '') || 'unknown'; @@ -1207,6 +1235,7 @@ export async function dispatchTool( // A hosted agent on the native engine is ready in the same sense. state: deriveActivityBucket(lastActiveAt, hostedRunsNative(i) ? 'native' : runtimeType), lastActiveAt: lastActiveAt ? new Date(lastActiveAt).toISOString() : null, + inThisPod: String(i.podId) === String(ctx.podId), }; }); return { content: { agents } };