Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions backend/__tests__/service/agent-manage-settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand Down
76 changes: 76 additions & 0 deletions backend/__tests__/service/native-agent-status.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
24 changes: 24 additions & 0 deletions backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
23 changes: 21 additions & 2 deletions backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,16 +225,35 @@ 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' }]]),
})]);
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([{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
27 changes: 19 additions & 8 deletions backend/config/native-agents/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`); 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 <name>`, 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 '
Expand Down Expand Up @@ -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 '
Comment thread
coderabbitai[bot] marked this conversation as resolved.
+ 'remember you are the product\'s first impression: an invented product '
+ 'fact from you is worse than one from anyone else.',
),
Expand Down
17 changes: 12 additions & 5 deletions backend/routes/registry/manage-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 };
Expand All @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions backend/services/agentConnectionStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) => {
Expand Down Expand Up @@ -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<number>((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(),
};
};
Loading
Loading