From caf2648311a6c49497bcfe0f904f72b504edcd7c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:57:01 -0700 Subject: [PATCH 1/5] fix(status): agent status follows the agent's own key, and Scout checks it live (D172) An agent on a connected computer was marked connected from the computer's daemon heartbeat alone. A listener polling with its own key showed offline when the daemon was quiet, and a dead listener behind a live daemon showed connected. The roster and the Settings process line now decide from the agent's runtime key use within five minutes; the heartbeat stays on the computer row. Scout read the BYO seat's install intro ("nothing is running me yet") from pod history and repeated it to a user whose agent was polling. Its prompt now requires a commonly_agent_status call before any running-or-not claim and forbids judging that from earlier messages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../service/agent-manage-settings.test.js | 19 +++++++++++++++ .../nativeAgents.scoutKnowledge.test.js | 13 +++++++++++ .../registry.pod-agents-activity.test.js | 23 +++++++++++++++++-- .../agentConnectionStateService.test.js | 16 +++++++++++-- backend/config/native-agents/scout.ts | 12 ++++++---- backend/routes/registry/manage-settings.ts | 17 ++++++++++---- .../services/agentConnectionStateService.ts | 13 +++++++---- 7 files changed, 94 insertions(+), 19 deletions(-) diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index e569d8a0f1..6fb890843a 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__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index dfe799aedf..e2fd78dff1 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -290,3 +290,16 @@ 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'); + }); +}); 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 330de9ddd8..bf071d2e38 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 fa1918b1d9..e8c031d8cc 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/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index 1292009453..dc5c6972ae 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -253,9 +253,11 @@ 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 ' + + '- 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. 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' @@ -309,8 +311,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 4d8309ce09..5c1fb2cbe2 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 b391fe712b..348116accc 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(), }; }; From 6bee1e05c3632b82d431a651c35e85156a9dccc6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:58:23 -0700 Subject: [PATCH 2/5] fix(status): Scout's status tool sees the asker's agents in every pod and all key stores (D172) commonly_agent_status listed only the current pod's installs, so a seat connected from another pod was missing when its owner asked Scout about it. It now also lists the asker's own installs elsewhere (name and state only, marked inThisPod: false). collectPodAgentActivity read token use only from the bot User's embedded tokens. Runtime auth stamps lastUsedAt on whichever store authenticated the call, so ledger credentials (connect page, daemon mints, spawn children) and legacy installation tokens now count too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../service/native-agent-status.test.js | 76 +++++++++++++++++++ backend/services/agentStateService.ts | 33 +++++++- backend/services/nativeRuntimeService.ts | 38 ++++++++-- 3 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 backend/__tests__/service/native-agent-status.test.js 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 0000000000..479946c674 --- /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/services/agentStateService.ts b/backend/services/agentStateService.ts index 87c49d983d..73031aef8d 100644 --- a/backend/services/agentStateService.ts +++ b/backend/services/agentStateService.ts @@ -162,6 +162,7 @@ interface ActivityInstallationLike { agentName: string; instanceId?: string; config?: { runtime?: { runtimeType?: string } }; + runtimeTokens?: Array<{ lastUsedAt?: Date | null }>; } /** @@ -252,19 +253,43 @@ 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. + const credentialTimeByUser = new Map(); + try { + // 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); + } + } catch (credentialErr) { + console.warn('[agent-state] credential activity lookup failed:', (credentialErr as Error).message); + } + 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 f118a431ba..56f3ec0db1 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -1191,12 +1191,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 +1234,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 } }; From d7d9d4a6120cddb35eaecb539dd30f997c63386a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:07:11 -0700 Subject: [PATCH 3/5] fix(status): Scout reports active as a recent check-in; ledger read failures propagate (D172) Answers CodeRabbit on #2969. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- backend/config/native-agents/scout.ts | 5 ++++- backend/services/agentStateService.ts | 31 +++++++++++++-------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index dc5c6972ae..2e788a70ae 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -257,7 +257,10 @@ export const scoutApp = { + '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. never-connected means the connect-page step was never finished ' + + 'at install, before anything started it. Report its states as recent ' + + 'check-ins, with the lastActiveAt time: active means it checked in within ' + + 'the last 10 minutes, not that it is working on something right now. ' + + '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' diff --git a/backend/services/agentStateService.ts b/backend/services/agentStateService.ts index 73031aef8d..4b65f088d6 100644 --- a/backend/services/agentStateService.ts +++ b/backend/services/agentStateService.ts @@ -264,23 +264,22 @@ export const collectPodAgentActivity = async ( // 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(); - try { - // 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); - } - } catch (credentialErr) { - console.warn('[agent-state] credential activity lookup failed:', (credentialErr as Error).message); + // 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) { From bef9c345228b5abbb4cd68012654336137edcb8e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:47:16 -0700 Subject: [PATCH 4/5] fix(status): Scout names idle with restart advice; one five-minute active window (D172) Answers rhea's HOLD on #2969. idle (5 min to a day) had no rule in Scout's prompt, so a listener that stopped half an hour ago got no restart advice for a day. The active bucket now shares BYO_STALE_MS with the roster and Settings verdict, so Scout and the page agree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../config/nativeAgents.scoutKnowledge.test.js | 10 ++++++++++ .../services/agentStateService.activity.test.js | 4 ++++ backend/config/native-agents/scout.ts | 16 ++++++++++------ backend/services/agentStateService.ts | 6 +++++- backend/services/nativeRuntimeService.ts | 5 +++-- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index e2fd78dff1..351ef0ccb7 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -302,4 +302,14 @@ describe('Scout running-state claims', () => { 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'); + }); }); diff --git a/backend/__tests__/unit/services/agentStateService.activity.test.js b/backend/__tests__/unit/services/agentStateService.activity.test.js index 0aca1dc1ef..efc5611ed0 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 2e788a70ae..2bccb341e4 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -258,12 +258,16 @@ export const scoutApp = { + '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 means it checked in within ' - + 'the last 10 minutes, not that it is working on something right now. ' - + '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' + + '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 it ' + + 'has had no turns lately; nothing needs fixing. 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 ' diff --git a/backend/services/agentStateService.ts b/backend/services/agentStateService.ts index 4b65f088d6..4548d8f290 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'; diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 56f3ec0db1..d43af67ba4 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 ' From 1d5bde6480193beb66e32554e896fc3f9a345d4c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:58:39 -0700 Subject: [PATCH 5/5] fix(scout): a hosted agent's idle state is not a health verdict (D172) CodeRabbit on #2969: activity is the last run start, not its outcome, so Scout gives the last activity time and never says nothing needs fixing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../unit/config/nativeAgents.scoutKnowledge.test.js | 1 + backend/config/native-agents/scout.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index 351ef0ccb7..e00a982c8b 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -311,5 +311,6 @@ describe('Scout running-state claims', () => { } 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/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index 2bccb341e4..e8975f7bac 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -264,8 +264,10 @@ export const scoutApp = { + '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 it ' - + 'has had no turns lately; nothing needs fixing. never-connected means ' + + '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'