From cd0be4bc937a429c900d20fdb87d1c1fab72637f Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 11:39:02 +0200 Subject: [PATCH 01/14] feat(smithy): add disabled flag to BaseAgentMetadata Co-Authored-By: Claude Sonnet 4.6 --- packages/smithy/src/types/agent.bun.test.ts | 18 +++++++++++++++++- packages/smithy/src/types/agent.ts | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/smithy/src/types/agent.bun.test.ts b/packages/smithy/src/types/agent.bun.test.ts index bfa06c9a..e9a6a1fe 100644 --- a/packages/smithy/src/types/agent.bun.test.ts +++ b/packages/smithy/src/types/agent.bun.test.ts @@ -2,7 +2,7 @@ * Agent Types Tests */ -import { describe, test, expect } from 'bun:test'; +import { describe, it, test, expect } from 'bun:test'; import type { CronTrigger, EventTrigger, @@ -157,6 +157,22 @@ describe('AgentMetadata type guards', () => { }); }); +describe('disabled flag', () => { + it('accepts disabled: true on each role', () => { + const director: DirectorMetadata = { agentRole: 'director', sessionStatus: 'idle', disabled: true }; + const worker: WorkerMetadata = { agentRole: 'worker', workerMode: 'ephemeral', sessionStatus: 'idle', disabled: true }; + const steward: StewardMetadata = { agentRole: 'steward', stewardFocus: 'merge', sessionStatus: 'idle', disabled: true }; + expect(director.disabled).toBe(true); + expect(worker.disabled).toBe(true); + expect(steward.disabled).toBe(true); + }); + + it('treats omitted disabled as enabled', () => { + const director: DirectorMetadata = { agentRole: 'director', sessionStatus: 'idle' }; + expect(director.disabled).toBeUndefined(); + }); +}); + describe('validateAgentMetadata', () => { test('validates director metadata', () => { expect(validateAgentMetadata({ agentRole: 'director' })).toBe(true); diff --git a/packages/smithy/src/types/agent.ts b/packages/smithy/src/types/agent.ts index 52374c4d..90c41527 100644 --- a/packages/smithy/src/types/agent.ts +++ b/packages/smithy/src/types/agent.ts @@ -187,6 +187,8 @@ export interface BaseAgentMetadata { readonly model?: string; /** Custom executable path for the agent's provider CLI (e.g., '/usr/local/bin/claude'). If not set, uses provider default. */ readonly executablePath?: string; + /** When true, this agent is parked and excluded from dispatch and scheduler triggers. Defaults to false (omitted). */ + readonly disabled?: boolean; } /** From 33013dbaa2971377e2b890d43b7755c4038f5e7c Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 11:41:16 +0200 Subject: [PATCH 02/14] feat(smithy): add isAgentDisabled / isAgentEnabled registry helpers Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/agent-registry.bun.test.ts | 31 +++++++++++++++++++ .../smithy/src/services/agent-registry.ts | 10 ++++++ 2 files changed, 41 insertions(+) diff --git a/packages/smithy/src/services/agent-registry.bun.test.ts b/packages/smithy/src/services/agent-registry.bun.test.ts index 6f69f7ec..2d0434bc 100644 --- a/packages/smithy/src/services/agent-registry.bun.test.ts +++ b/packages/smithy/src/services/agent-registry.bun.test.ts @@ -14,6 +14,8 @@ import { type AgentRegistry, generateAgentChannelName, parseAgentChannelName, + isAgentDisabled, + isAgentEnabled, } from './agent-registry.js'; import { type AgentEntity, @@ -695,3 +697,32 @@ describe('AgentRegistry', () => { }); }); }); + +describe('isAgentDisabled / isAgentEnabled', () => { + const baseAgent = { + id: 'el-test', + type: 'entity' as const, + createdAt: '2026-04-29T00:00:00Z', + updatedAt: '2026-04-29T00:00:00Z', + createdBy: 'el-0000', + tags: [], + metadata: { agent: { agentRole: 'worker' as const, workerMode: 'ephemeral' as const, sessionStatus: 'idle' as const } }, + }; + + test('returns false when disabled is undefined', () => { + expect(isAgentDisabled(baseAgent as never)).toBe(false); + expect(isAgentEnabled(baseAgent as never)).toBe(true); + }); + + test('returns true when disabled is true', () => { + const disabled = { ...baseAgent, metadata: { agent: { ...baseAgent.metadata.agent, disabled: true } } }; + expect(isAgentDisabled(disabled as never)).toBe(true); + expect(isAgentEnabled(disabled as never)).toBe(false); + }); + + test('returns false when disabled is explicitly false', () => { + const enabled = { ...baseAgent, metadata: { agent: { ...baseAgent.metadata.agent, disabled: false } } }; + expect(isAgentDisabled(enabled as never)).toBe(false); + expect(isAgentEnabled(enabled as never)).toBe(true); + }); +}); diff --git a/packages/smithy/src/services/agent-registry.ts b/packages/smithy/src/services/agent-registry.ts index 667d1833..96e7b004 100644 --- a/packages/smithy/src/services/agent-registry.ts +++ b/packages/smithy/src/services/agent-registry.ts @@ -43,6 +43,16 @@ import { export type { AgentEntity }; export { isAgentEntity, getAgentMetadata }; +/** Returns true if the agent is disabled (parked from dispatch and scheduling). */ +export function isAgentDisabled(agent: AgentEntity): boolean { + return getAgentMetadata(agent)?.disabled === true; +} + +/** Returns true if the agent is not disabled. Inverse of isAgentDisabled. */ +export function isAgentEnabled(agent: AgentEntity): boolean { + return !isAgentDisabled(agent); +} + // ============================================================================ // Constants // ============================================================================ From 08d7abdf6127a977fd8a20c2548ed0a5560851f0 Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 11:45:40 +0200 Subject: [PATCH 03/14] feat(smithy): add sf agent disable and enable commands Expose updateAgentMetadata on OrchestratorAPI interface (was private) so CLI handlers can flip metadata.agent.disabled without bypassing the API layer. Add agentDisableCommand and agentEnableCommand mirroring the agentStopCommand pattern, register them in agentCommand subcommands, and update the help string. Add structure and validation tests. Co-Authored-By: Claude Sonnet 4.6 --- packages/smithy/src/api/orchestrator-api.ts | 12 +++- .../smithy/src/cli/commands/agent.bun.test.ts | 38 ++++++++++ packages/smithy/src/cli/commands/agent.ts | 72 ++++++++++++++++++- 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/smithy/src/api/orchestrator-api.ts b/packages/smithy/src/api/orchestrator-api.ts index 24c0dbe5..3c31aaa7 100644 --- a/packages/smithy/src/api/orchestrator-api.ts +++ b/packages/smithy/src/api/orchestrator-api.ts @@ -230,6 +230,14 @@ export interface OrchestratorAPI extends QuarryAPI { markAsStarted?: boolean; } ): Promise; + + /** + * Updates an agent's metadata (merges with existing metadata) + */ + updateAgentMetadata( + agentId: EntityId, + updates: Partial + ): Promise; } // ============================================================================ @@ -629,9 +637,9 @@ export class OrchestratorAPIImpl extends QuarryAPIImpl implements OrchestratorAP } /** - * Updates an agent's metadata + * Updates an agent's metadata (merges with existing metadata) */ - private async updateAgentMetadata( + async updateAgentMetadata( agentId: EntityId, updates: Partial ): Promise { diff --git a/packages/smithy/src/cli/commands/agent.bun.test.ts b/packages/smithy/src/cli/commands/agent.bun.test.ts index 6e61180d..392d6062 100644 --- a/packages/smithy/src/cli/commands/agent.bun.test.ts +++ b/packages/smithy/src/cli/commands/agent.bun.test.ts @@ -14,6 +14,8 @@ import { agentStartCommand, agentStopCommand, agentStreamCommand, + agentDisableCommand, + agentEnableCommand, } from './agent.js'; describe('Agent Command Structure', () => { @@ -31,6 +33,8 @@ describe('Agent Command Structure', () => { expect(agentCommand.subcommands!.start).toBe(agentStartCommand); expect(agentCommand.subcommands!.stop).toBe(agentStopCommand); expect(agentCommand.subcommands!.stream).toBe(agentStreamCommand); + expect(agentCommand.subcommands!.disable).toBe(agentDisableCommand); + expect(agentCommand.subcommands!.enable).toBe(agentEnableCommand); }); it('should default to list handler', () => { @@ -219,6 +223,24 @@ describe('Agent Command Structure', () => { expect(typeof agentStreamCommand.handler).toBe('function'); }); }); + + describe('agentDisableCommand', () => { + it('should have correct structure', () => { + expect(agentDisableCommand.name).toBe('disable'); + expect(agentDisableCommand.description).toBe('Disable an agent (skipped by dispatch and scheduler, kept in the list)'); + expect(agentDisableCommand.usage).toBe('sf agent disable '); + expect(typeof agentDisableCommand.handler).toBe('function'); + }); + }); + + describe('agentEnableCommand', () => { + it('should have correct structure', () => { + expect(agentEnableCommand.name).toBe('enable'); + expect(agentEnableCommand.description).toBe('Enable a previously disabled agent'); + expect(agentEnableCommand.usage).toBe('sf agent enable '); + expect(typeof agentEnableCommand.handler).toBe('function'); + }); + }); }); describe('Agent Command Validation', () => { @@ -273,4 +295,20 @@ describe('Agent Command Validation', () => { expect(result.error).toContain('Usage'); }); }); + + describe('agentDisableCommand', () => { + it('should fail without id argument', async () => { + const result = await agentDisableCommand.handler([], {}); + expect(result.exitCode).not.toBe(0); + expect(result.error).toContain('Usage'); + }); + }); + + describe('agentEnableCommand', () => { + it('should fail without id argument', async () => { + const result = await agentEnableCommand.handler([], {}); + expect(result.exitCode).not.toBe(0); + expect(result.error).toContain('Usage'); + }); + }); }); diff --git a/packages/smithy/src/cli/commands/agent.ts b/packages/smithy/src/cli/commands/agent.ts index fc596fef..a74aa92c 100644 --- a/packages/smithy/src/cli/commands/agent.ts +++ b/packages/smithy/src/cli/commands/agent.ts @@ -13,7 +13,7 @@ import type { Command, GlobalOptions, CommandResult, CommandOption } from '@stoneforge/quarry/cli'; import { success, failure, ExitCode, getFormatter, getOutputMode, OPERATOR_ENTITY_ID } from '@stoneforge/quarry/cli'; import type { EntityId, ElementId } from '@stoneforge/core'; -import type { AgentRole, WorkerMode, StewardFocus } from '../../types/index.js'; +import type { AgentRole, WorkerMode, StewardFocus, AgentMetadata } from '../../types/index.js'; import type { OrchestratorAPI, AgentEntity } from '../../api/index.js'; // ============================================================================ @@ -1046,6 +1046,72 @@ Examples: handler: agentStartHandler as Command['handler'], }; +// ============================================================================ +// Agent Disable Command +// ============================================================================ + +async function agentDisableHandler( + args: string[], + options: GlobalOptions +): Promise { + const [id] = args; + if (!id) { + return failure('Usage: sf agent disable ', ExitCode.INVALID_ARGUMENTS); + } + const { api, error } = await createOrchestratorClient(options); + if (error || !api) return failure(error ?? 'Failed to create API', ExitCode.GENERAL_ERROR); + + const agent = await api.getAgent(id as EntityId); + if (!agent) return failure(`Agent not found: ${id}`, ExitCode.NOT_FOUND); + + await api.updateAgentMetadata(id as EntityId, { disabled: true } as Partial); + return success({ id, disabled: true }, `Agent ${id} disabled. It will be skipped by dispatch and the scheduler.`); +} + +export const agentDisableCommand: Command = { + name: 'disable', + description: 'Disable an agent (skipped by dispatch and scheduler, kept in the list)', + usage: 'sf agent disable ', + help: `Mark an agent as disabled. The agent stays visible in 'sf agent list' but is skipped by the dispatch daemon and steward scheduler. In-flight sessions are NOT terminated; only future work is blocked. Re-enable with 'sf agent enable '. + +Arguments: + id Agent identifier`, + handler: agentDisableHandler as Command['handler'], +}; + +// ============================================================================ +// Agent Enable Command +// ============================================================================ + +async function agentEnableHandler( + args: string[], + options: GlobalOptions +): Promise { + const [id] = args; + if (!id) { + return failure('Usage: sf agent enable ', ExitCode.INVALID_ARGUMENTS); + } + const { api, error } = await createOrchestratorClient(options); + if (error || !api) return failure(error ?? 'Failed to create API', ExitCode.GENERAL_ERROR); + + const agent = await api.getAgent(id as EntityId); + if (!agent) return failure(`Agent not found: ${id}`, ExitCode.NOT_FOUND); + + await api.updateAgentMetadata(id as EntityId, { disabled: undefined } as Partial); + return success({ id, disabled: false }, `Agent ${id} enabled.`); +} + +export const agentEnableCommand: Command = { + name: 'enable', + description: 'Enable a previously disabled agent', + usage: 'sf agent enable ', + help: `Re-enable a disabled agent so it is considered again by dispatch and the scheduler. + +Arguments: + id Agent identifier`, + handler: agentEnableHandler as Command['handler'], +}; + // ============================================================================ // Main Agent Command // ============================================================================ @@ -1063,6 +1129,8 @@ Subcommands: start Start an agent process stop Stop an agent session stream Get agent channel for streaming + disable Disable an agent (skipped by dispatch and scheduler) + enable Re-enable a previously disabled agent Examples: sf agent list @@ -1076,6 +1144,8 @@ Examples: start: agentStartCommand, stop: agentStopCommand, stream: agentStreamCommand, + disable: agentDisableCommand, + enable: agentEnableCommand, // Aliases (hidden from --help via dedup in getCommandHelp) create: agentRegisterCommand, ls: agentListCommand, From 827695372c9a27be32f1ce17cb0d408cdcc70eb8 Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 12:02:19 +0200 Subject: [PATCH 04/14] test(smithy): add behavioural round-trip tests for agent disable/enable Adds two tests that verify the actual metadata mutation: - disable round-trip: updateAgentMetadata sets disabled to true - enable round-trip: updateAgentMetadata removes disabled key from serialised JSON Uses an in-memory SQLite-backed OrchestratorAPI (same harness pattern as agent-registry.bun.test.ts) rather than invoking the handler literally, because createOrchestratorClient() always calls findStoneforgeDir() before honouring options.db - there is no .stoneforge dir in the test environment. The handler bodies are thin wrappers around api.updateAgentMetadata, so the coverage is equivalent. Co-Authored-By: Claude Sonnet 4.6 --- .../smithy/src/cli/commands/agent.bun.test.ts | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/smithy/src/cli/commands/agent.bun.test.ts b/packages/smithy/src/cli/commands/agent.bun.test.ts index 392d6062..1d9f38bb 100644 --- a/packages/smithy/src/cli/commands/agent.bun.test.ts +++ b/packages/smithy/src/cli/commands/agent.bun.test.ts @@ -2,10 +2,14 @@ * Agent Command Tests * * Tests for orchestrator CLI agent commands structure and validation. - * Note: Full integration tests would require database setup. */ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, test, beforeEach } from 'bun:test'; +import { createStorage, initializeSchema } from '@stoneforge/quarry'; +import { createOrchestratorAPI } from '../../api/index.js'; +import { isAgentDisabled } from '../../services/agent-registry.js'; +import type { EntityId } from '@stoneforge/core'; +import type { AgentMetadata } from '../../types/index.js'; import { agentCommand, agentListCommand, @@ -312,3 +316,72 @@ describe('Agent Command Validation', () => { }); }); }); + +// ============================================================================ +// Behavioural round-trip tests for agent disable / enable +// +// The CLI handlers call createOrchestratorClient(), which runs +// findStoneforgeDir(process.cwd()) before honouring options.db. Invoking the +// handler literally in a test environment (no .stoneforge dir on disk) would +// always hit the "Run sf init first" early-return path, so these tests verify +// the mutation behaviour by calling the SAME api methods the handlers call, +// using an in-memory SQLite backend. The handler bodies are intentionally +// thin wrappers around api.updateAgentMetadata, so this approach gives full +// coverage of the observable effect without duplicating handler logic. +// ============================================================================ + +describe('agent disable / enable behavioural', () => { + // Reuse the OPERATOR_ENTITY_ID constant ('el-0000') as the creator. + // It is a valid EntityId string - no DB row is needed for it since + // registerWorker only stores the value, it does not foreign-key-check it. + const CREATOR = 'el-0000' as EntityId; + + let api: ReturnType; + let agentId: EntityId; + + beforeEach(async () => { + const backend = createStorage({ path: ':memory:' }); + initializeSchema(backend); + api = createOrchestratorAPI(backend); + + const registered = await api.registerWorker({ + name: 'test-worker', + workerMode: 'ephemeral', + createdBy: CREATOR, + }); + agentId = registered.id as unknown as EntityId; + }); + + test('disable round-trip: updateAgentMetadata sets disabled to true', async () => { + // This mirrors exactly what agentDisableHandler does after resolving the API. + await api.updateAgentMetadata(agentId, { disabled: true } as Partial); + + const agent = await api.getAgent(agentId); + expect(agent).toBeDefined(); + expect((agent!.metadata.agent as { disabled?: boolean }).disabled).toBe(true); + expect(isAgentDisabled(agent!)).toBe(true); + }); + + test('enable round-trip: updateAgentMetadata removes disabled key from serialised JSON', async () => { + // First disable the agent, then re-enable it - same sequence as the two + // handlers called back-to-back. + await api.updateAgentMetadata(agentId, { disabled: true } as Partial); + // Sanity-check that it is actually disabled before we enable it. + const disabledAgent = await api.getAgent(agentId); + expect(isAgentDisabled(disabledAgent!)).toBe(true); + + // This mirrors exactly what agentEnableHandler does after resolving the API. + await api.updateAgentMetadata(agentId, { disabled: undefined } as Partial); + + const agent = await api.getAgent(agentId); + expect(agent).toBeDefined(); + + // isAgentDisabled must return false after the enable call. + expect(isAgentDisabled(agent!)).toBe(false); + + // JSON.stringify drops undefined values, so the serialised agent metadata + // must contain no "disabled" key - this is the contract that + // absent-means-enabled relies on. + expect(JSON.stringify(agent!.metadata!.agent).includes('"disabled"')).toBe(false); + }); +}); From 4e1a84b49aced716298e6cf2f3a2c676b3d91167 Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 12:05:10 +0200 Subject: [PATCH 05/14] feat(smithy): refuse sf agent start on disabled agents Add isAgentDisabled guard to agentStartHandler so a disabled agent returns a VALIDATION failure pointing the user at sf agent enable. Covers the new path with a chdir-based behavioural test that exercises the real handler via a tmpdir .stoneforge setup. Co-Authored-By: Claude Sonnet 4.6 --- .../smithy/src/cli/commands/agent.bun.test.ts | 46 +++++++++++++++++++ packages/smithy/src/cli/commands/agent.ts | 8 ++++ 2 files changed, 54 insertions(+) diff --git a/packages/smithy/src/cli/commands/agent.bun.test.ts b/packages/smithy/src/cli/commands/agent.bun.test.ts index 1d9f38bb..4e61b169 100644 --- a/packages/smithy/src/cli/commands/agent.bun.test.ts +++ b/packages/smithy/src/cli/commands/agent.bun.test.ts @@ -5,6 +5,9 @@ */ import { describe, it, expect, test, beforeEach } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createStorage, initializeSchema } from '@stoneforge/quarry'; import { createOrchestratorAPI } from '../../api/index.js'; import { isAgentDisabled } from '../../services/agent-registry.js'; @@ -384,4 +387,47 @@ describe('agent disable / enable behavioural', () => { // absent-means-enabled relies on. expect(JSON.stringify(agent!.metadata!.agent).includes('"disabled"')).toBe(false); }); + + // ------------------------------------------------------------------------- + // agentStartHandler refusal test + // + // createOrchestratorClient calls findStoneforgeDir(process.cwd()), so we + // chdir into a tmpdir that has a .stoneforge subdir containing a real SQLite + // DB. This lets us exercise the actual handler code path (including the + // isAgentDisabled guard) without spawning a process. + // ------------------------------------------------------------------------- + test('start refuses on a disabled agent', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'sf-disable-test-')); + const cwdBefore = process.cwd(); + try { + mkdirSync(join(tmpRoot, '.stoneforge'), { recursive: true }); + const dbPath = join(tmpRoot, '.stoneforge', 'stoneforge.db'); + const tmpBackend = createStorage({ path: dbPath, create: true }); + initializeSchema(tmpBackend); + const tmpApi = createOrchestratorAPI(tmpBackend); + const registered = await tmpApi.registerWorker({ + name: 'disabled-worker', + workerMode: 'ephemeral', + createdBy: CREATOR, + }); + // Disable the agent via the API (same as agentDisableHandler would do). + await tmpApi.updateAgentMetadata(registered.id as unknown as EntityId, { disabled: true } as Partial); + + process.chdir(tmpRoot); + try { + const result = await agentStartCommand.handler!( + [registered.id as unknown as string], + { db: dbPath } as never + ); + expect(result.exitCode).not.toBe(0); + const errMsg = String(result.error ?? ''); + expect(errMsg.toLowerCase()).toContain('disabled'); + expect(errMsg).toContain('sf agent enable'); + } finally { + process.chdir(cwdBefore); + } + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } + }); }); diff --git a/packages/smithy/src/cli/commands/agent.ts b/packages/smithy/src/cli/commands/agent.ts index a74aa92c..cad0975e 100644 --- a/packages/smithy/src/cli/commands/agent.ts +++ b/packages/smithy/src/cli/commands/agent.ts @@ -15,6 +15,7 @@ import { success, failure, ExitCode, getFormatter, getOutputMode, OPERATOR_ENTIT import type { EntityId, ElementId } from '@stoneforge/core'; import type { AgentRole, WorkerMode, StewardFocus, AgentMetadata } from '../../types/index.js'; import type { OrchestratorAPI, AgentEntity } from '../../api/index.js'; +import { isAgentDisabled } from '../../services/agent-registry.js'; // ============================================================================ // Shared Helpers @@ -902,6 +903,13 @@ async function agentStartHandler( return failure(`Agent not found: ${id}`, ExitCode.NOT_FOUND); } + if (isAgentDisabled(agent)) { + return failure( + `Agent ${id} is disabled. Run 'sf agent enable ${id}' first to bring it back online.`, + ExitCode.VALIDATION + ); + } + const meta = getAgentMeta(agent); const agentRole = (meta.agentRole as AgentRole) ?? 'worker'; From aca424ddd8450ead672f97f869ed6fe12c5fe709 Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 12:07:58 +0200 Subject: [PATCH 06/14] feat(smithy): mark disabled agents in sf agent list output Append '(disabled)' to the status column in the table formatter when an agent's metadata has disabled: true. JSON and quiet output paths are unchanged. Adds a behavioural test that asserts the annotation is present for the disabled row and absent for the enabled row. Co-Authored-By: Claude Sonnet 4.6 --- .../smithy/src/cli/commands/agent.bun.test.ts | 52 +++++++++++++++++++ packages/smithy/src/cli/commands/agent.ts | 4 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/smithy/src/cli/commands/agent.bun.test.ts b/packages/smithy/src/cli/commands/agent.bun.test.ts index 4e61b169..d762c70b 100644 --- a/packages/smithy/src/cli/commands/agent.bun.test.ts +++ b/packages/smithy/src/cli/commands/agent.bun.test.ts @@ -388,6 +388,58 @@ describe('agent disable / enable behavioural', () => { expect(JSON.stringify(agent!.metadata!.agent).includes('"disabled"')).toBe(false); }); + // ------------------------------------------------------------------------- + // agentListHandler annotation test + // + // chdir into a tmpdir with a real .stoneforge/stoneforge.db so that + // createOrchestratorClient can resolve the DB and the handler runs end-to-end. + // ------------------------------------------------------------------------- + test('list output marks disabled agents', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'sf-disable-list-')); + try { + mkdirSync(join(tmpRoot, '.stoneforge'), { recursive: true }); + const dbPath = join(tmpRoot, '.stoneforge', 'stoneforge.db'); + const tmpBackend = createStorage({ path: dbPath, create: true }); + initializeSchema(tmpBackend); + const tmpApi = createOrchestratorAPI(tmpBackend); + + await tmpApi.registerWorker({ + name: 'enabled-w', + workerMode: 'ephemeral', + createdBy: CREATOR, + }); + const disabledRegistered = await tmpApi.registerWorker({ + name: 'disabled-w', + workerMode: 'ephemeral', + createdBy: CREATOR, + }); + await tmpApi.updateAgentMetadata( + disabledRegistered.id as unknown as EntityId, + { disabled: true } as Partial + ); + + const cwdBefore = process.cwd(); + process.chdir(tmpRoot); + try { + const result = await agentListCommand.handler!([], { db: dbPath } as never); + expect(result.exitCode).toBe(0); + const out = String(result.message ?? ''); + // Disabled agent should have (disabled) in its row + const disabledLine = out.split('\n').find(l => l.includes('disabled-w')); + expect(disabledLine).toBeTruthy(); + expect(disabledLine).toContain('(disabled)'); + // Enabled agent must NOT have the marker + const enabledLine = out.split('\n').find(l => l.includes('enabled-w')); + expect(enabledLine).toBeTruthy(); + expect(enabledLine).not.toContain('(disabled)'); + } finally { + process.chdir(cwdBefore); + } + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + // ------------------------------------------------------------------------- // agentStartHandler refusal test // diff --git a/packages/smithy/src/cli/commands/agent.ts b/packages/smithy/src/cli/commands/agent.ts index cad0975e..e49155eb 100644 --- a/packages/smithy/src/cli/commands/agent.ts +++ b/packages/smithy/src/cli/commands/agent.ts @@ -274,11 +274,13 @@ async function agentListHandler( const headers = ['ID', 'NAME', 'ROLE', 'STATUS', 'SESSION']; const rows = agents.map((agent) => { const meta = getAgentMeta(agent); + const baseStatus = (meta.sessionStatus as string) ?? 'idle'; + const status = meta.disabled === true ? `${baseStatus} (disabled)` : baseStatus; return [ agent.id, agent.name ?? '-', (meta.agentRole as string) ?? '-', - (meta.sessionStatus as string) ?? 'idle', + status, (meta.sessionId as string)?.slice(0, 8) ?? '-', ]; }); From 71fdf8b005710d9d7a949af7832f3d246343f05d Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 12:14:06 +0200 Subject: [PATCH 07/14] feat(smithy): skip disabled agents in dispatch and getAvailableDirector Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/agent-registry.bun.test.ts | 52 +++++++++++++++++++ .../smithy/src/services/agent-registry.ts | 2 +- .../src/services/dispatch-daemon.bun.test.ts | 40 ++++++++++++++ .../smithy/src/services/dispatch-daemon.ts | 10 +++- 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/smithy/src/services/agent-registry.bun.test.ts b/packages/smithy/src/services/agent-registry.bun.test.ts index 2d0434bc..7a6f070c 100644 --- a/packages/smithy/src/services/agent-registry.bun.test.ts +++ b/packages/smithy/src/services/agent-registry.bun.test.ts @@ -726,3 +726,55 @@ describe('isAgentDisabled / isAgentEnabled', () => { expect(isAgentEnabled(enabled as never)).toBe(true); }); }); + +describe('getAvailableDirector with disabled', () => { + let registry: AgentRegistry; + let api: QuarryAPI; + let testDbPath: string; + let systemEntity: EntityId; + + beforeEach(async () => { + testDbPath = `/tmp/agent-registry-director-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`; + const storage = createStorage(testDbPath); + initializeSchema(storage); + api = createQuarryAPI(storage); + registry = createAgentRegistry(api); + + const entity = await createEntity({ + name: 'test-system', + entityType: EntityTypeValue.SYSTEM, + createdBy: 'system:test' as EntityId, + }); + const saved = await api.create(entity as unknown as Record & { createdBy: EntityId }); + systemEntity = saved.id as unknown as EntityId; + }); + + afterEach(() => { + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath); + } + }); + + test('does not return a disabled director even if its session is running', async () => { + const enabled = await registry.registerDirector({ + name: 'CEO-1', + createdBy: systemEntity, + }); + const disabled = await registry.registerDirector({ + name: 'CEO-2', + createdBy: systemEntity, + }); + + // Mark both as running, then mark the second as disabled. + await registry.updateAgentMetadata(enabled.id as unknown as EntityId, { + sessionStatus: 'running', + } as never); + await registry.updateAgentMetadata(disabled.id as unknown as EntityId, { + sessionStatus: 'running', + disabled: true, + } as never); + + const result = await registry.getAvailableDirector(); + expect(result?.id).toBe(enabled.id); + }); +}); diff --git a/packages/smithy/src/services/agent-registry.ts b/packages/smithy/src/services/agent-registry.ts index 96e7b004..8ba80ddc 100644 --- a/packages/smithy/src/services/agent-registry.ts +++ b/packages/smithy/src/services/agent-registry.ts @@ -470,7 +470,7 @@ export class AgentRegistryImpl implements AgentRegistry { const directors = await this.getDirectors(); const running = directors.filter(d => { const meta = getAgentMetadata(d); - return meta?.sessionStatus === 'running'; + return meta?.sessionStatus === 'running' && !isAgentDisabled(d); }); if (preferredId) { const preferred = running.find(d => d.id === asElementId(preferredId)); diff --git a/packages/smithy/src/services/dispatch-daemon.bun.test.ts b/packages/smithy/src/services/dispatch-daemon.bun.test.ts index 10f5f415..d278cf55 100644 --- a/packages/smithy/src/services/dispatch-daemon.bun.test.ts +++ b/packages/smithy/src/services/dispatch-daemon.bun.test.ts @@ -6395,4 +6395,44 @@ describe('assignTaskToWorker - per-director targetBranch propagation', () => { const opts = lastCallArgs[0]; expect(opts.baseBranch).toBe('staging'); }); + + describe('disabled agents', () => { + test('does not dispatch to a disabled worker when it is the only candidate', async () => { + // Register only a disabled worker. No enabled worker exists. + const disabledWorker = await createTestWorker('disabled-worker'); + await agentRegistry.updateAgentMetadata( + disabledWorker.id as unknown as EntityId, + { disabled: true } as never + ); + + // Create a task that would normally be picked up. + await createTestTask('Task that must not go to disabled worker'); + + // Run the poll. With the filter in place, processed should be 0. + // Without the filter, it would be 1 (disabled worker gets the task). + const result = await daemon.pollWorkerAvailability(); + + expect(result.processed).toBe(0); + }); + + test('dispatches to enabled worker when disabled worker is also registered', async () => { + const enabledWorker = await createTestWorker('enabled-worker'); + const disabledWorker = await createTestWorker('disabled-worker'); + await agentRegistry.updateAgentMetadata( + disabledWorker.id as unknown as EntityId, + { disabled: true } as never + ); + + await createTestTask('Task for enabled worker only'); + + const result = await daemon.pollWorkerAvailability(); + + expect(result.processed).toBe(1); + // Confirm the disabled worker's id never appeared in startSession calls. + const startSessionCalls = (sessionManager.startSession as ReturnType).mock.calls; + const assignedIds = startSessionCalls.map((c: unknown[]) => c[0] as string); + expect(assignedIds).toContain(enabledWorker.id as unknown as string); + expect(assignedIds).not.toContain(disabledWorker.id as unknown as string); + }); + }); }); diff --git a/packages/smithy/src/services/dispatch-daemon.ts b/packages/smithy/src/services/dispatch-daemon.ts index ee43050e..550e7989 100644 --- a/packages/smithy/src/services/dispatch-daemon.ts +++ b/packages/smithy/src/services/dispatch-daemon.ts @@ -41,7 +41,7 @@ import { createLogger } from '../utils/logger.js'; import { isRateLimitMessage, parseRateLimitResetTime, getFallbackResetTime } from '../utils/rate-limit-parser.js'; import type { AgentRegistry, AgentEntity } from './agent-registry.js'; -import { getAgentMetadata } from './agent-registry.js'; +import { getAgentMetadata, isAgentDisabled } from './agent-registry.js'; import type { SessionManager, SessionRecord } from '../runtime/session-manager.js'; import type { DispatchService, DispatchOptions } from './dispatch-service.js'; import type { WorktreeManager, CreateWorktreeResult } from '../git/worktree-manager.js'; @@ -876,6 +876,7 @@ export class DispatchDaemonImpl implements DispatchDaemon { // defer task assignment so the next cycle's triage pass can handle them. const availableWorkers: AgentEntity[] = []; for (const worker of workers) { + if (isAgentDisabled(worker)) continue; const session = this.sessionManager.getActiveSession(asEntityId(worker.id)); if (session) continue; @@ -953,6 +954,7 @@ export class DispatchDaemonImpl implements DispatchDaemon { for (const agent of agents) { try { const agentId = asEntityId(agent.id); + if (isAgentDisabled(agent)) continue; const meta = getAgentMetadata(agent); if (!meta) continue; @@ -1083,9 +1085,10 @@ export class DispatchDaemonImpl implements DispatchDaemon { try { const stewards = await this.agentRegistry.getStewards(); - // Find available stewards (no active session) + // Find available stewards (no active session, not disabled) const availableStewards: AgentEntity[] = []; for (const steward of stewards) { + if (isAgentDisabled(steward)) continue; const session = this.sessionManager.getActiveSession(asEntityId(steward.id)); if (!session) { availableStewards.push(steward); @@ -1220,6 +1223,7 @@ export class DispatchDaemonImpl implements DispatchDaemon { const stewardsUsedThisCycle = new Set(); for (const worker of workers) { + if (isAgentDisabled(worker)) continue; const workerId = asEntityId(worker.id); // 2. Skip if worker has an active session @@ -1324,6 +1328,7 @@ export class DispatchDaemonImpl implements DispatchDaemon { }); for (const steward of mergeStewards) { + if (isAgentDisabled(steward)) continue; const stewardId = asEntityId(steward.id); // Skip if steward has an active session @@ -3434,6 +3439,7 @@ export class DispatchDaemonImpl implements DispatchDaemon { // cycle but may still have tasks assigned from the previous cycle. let recoverySteward: AgentEntity | undefined; for (const steward of stewards) { + if (isAgentDisabled(steward)) continue; const sid = asEntityId(steward.id); if (stewardsUsedThisCycle?.has(sid as string)) continue; const session = this.sessionManager.getActiveSession(sid); From 9b98ca311b73809557d724e506b0395f8c5f83b4 Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 12:22:55 +0200 Subject: [PATCH 08/14] feat(smithy): skip disabled stewards in scheduler registerSteward Add isAgentDisabled guard inside registerSteward so disabled stewards return false and register no cron or event triggers. registerAllStewards gets the filter for free since it calls registerSteward per agent. Co-Authored-By: Claude Sonnet 4.6 --- .../src/services/steward-scheduler.test.ts | 54 ++++++++++++++++++- .../smithy/src/services/steward-scheduler.ts | 7 ++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/smithy/src/services/steward-scheduler.test.ts b/packages/smithy/src/services/steward-scheduler.test.ts index 9ac4f048..2670acc2 100644 --- a/packages/smithy/src/services/steward-scheduler.test.ts +++ b/packages/smithy/src/services/steward-scheduler.test.ts @@ -38,13 +38,15 @@ function createMockAgentEntity( id: string, name: string, focus: 'merge' | 'docs' | 'recovery' | 'custom', - triggers: StewardTrigger[] = [] + triggers: StewardTrigger[] = [], + disabled?: boolean ): AgentEntity { const metadata: StewardMetadata = { agentRole: 'steward', stewardFocus: focus, triggers, sessionStatus: 'idle', + ...(disabled !== undefined ? { disabled } : {}), }; return { @@ -917,3 +919,53 @@ describe('StewardSchedulerImpl - logging', () => { await scheduler.stop(); }); }); + +// ============================================================================ +// Disabled Steward Tests +// ============================================================================ + +describe('disabled stewards', () => { + it('registerSteward returns false and registers nothing when the steward is disabled', async () => { + const disabledSteward = createMockAgentEntity( + 'steward-disabled', + 'Disabled Steward', + 'merge', + [{ type: 'cron', schedule: '*/5 * * * *' }], + true + ); + const registry = createMockAgentRegistry([disabledSteward]); + const executor = createMockExecutor(); + const scheduler = createStewardScheduler(registry, executor); + + const ok = await scheduler.registerSteward('steward-disabled' as EntityId); + expect(ok).toBe(false); + + // No cron jobs or event subscriptions should have been created. + const stats = scheduler.getStats(); + expect(stats.registeredStewards).toBe(0); + expect(stats.activeCronJobs).toBe(0); + expect(stats.activeEventSubscriptions).toBe(0); + }); + + it('registerAllStewards skips disabled stewards in the count', async () => { + const enabledSteward = createMockAgentEntity( + 's1', + 's1', + 'merge', + [{ type: 'cron', schedule: '*/5 * * * *' }] + ); + const disabledSteward = createMockAgentEntity( + 's2', + 's2', + 'docs', + [{ type: 'cron', schedule: '0 * * * *' }], + true + ); + const registry = createMockAgentRegistry([enabledSteward, disabledSteward]); + const executor = createMockExecutor(); + const scheduler = createStewardScheduler(registry, executor); + + const registered = await scheduler.registerAllStewards(); + expect(registered).toBe(1); + }); +}); diff --git a/packages/smithy/src/services/steward-scheduler.ts b/packages/smithy/src/services/steward-scheduler.ts index d8f80aa3..4a5b509c 100644 --- a/packages/smithy/src/services/steward-scheduler.ts +++ b/packages/smithy/src/services/steward-scheduler.ts @@ -41,7 +41,7 @@ import type { WorkflowPreset, AgentPermissionModel } from '@stoneforge/quarry'; import { AUTO_ALLOWED_TOOLS, AUTO_ALLOWED_SF_COMMANDS } from '../permissions/types.js'; import { detectTargetBranch } from '../git/merge.js'; import type { AgentRegistry, AgentEntity } from './agent-registry.js'; -import { getAgentMetadata } from './agent-registry.js'; +import { getAgentMetadata, isAgentDisabled } from './agent-registry.js'; import type { MergeStewardService } from './merge-steward-service.js'; import type { DocsStewardService } from './docs-steward-service.js'; import type { RateLimitTracker } from './rate-limit-tracker.js'; @@ -594,6 +594,11 @@ export class StewardSchedulerImpl implements StewardScheduler { return false; } + if (isAgentDisabled(agent)) { + logger.debug(`Skipping disabled steward ${stewardId}`); + return false; + } + const stewardMeta = metadata as StewardMetadata; const triggers = stewardMeta.triggers ?? []; From 1160ec0e8d045b3fb5a3c6b8215e332ca9f3a597 Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 12:32:41 +0200 Subject: [PATCH 09/14] chore: add changeset for agent disable feature --- .changeset/agent-disable.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/agent-disable.md diff --git a/.changeset/agent-disable.md b/.changeset/agent-disable.md new file mode 100644 index 00000000..5637bb63 --- /dev/null +++ b/.changeset/agent-disable.md @@ -0,0 +1,14 @@ +--- +"@stoneforge/smithy": minor +--- + +Add agent disable/enable: park agents from dispatch and scheduling without removing them from the agent list. + +- New `disabled?: boolean` flag on `BaseAgentMetadata`. Default omitted (treated as enabled). +- New CLI commands `sf agent disable ` and `sf agent enable `. +- `sf agent list` appends `(disabled)` to the status column when set. +- `sf agent start ` refuses with a hint pointing at `sf agent enable`. +- Dispatch daemon, `getAvailableDirector` in the registry, and the steward scheduler all skip disabled agents when selecting candidates for new work. +- In-flight sessions are NOT terminated when an agent is disabled; only future work is blocked. + +Closes #58. From d74f828fd4b82b433aa6fc0b843d2233bb803a38 Mon Sep 17 00:00:00 2001 From: Adrian Komorek Date: Thu, 30 Apr 2026 14:48:37 +0200 Subject: [PATCH 10/14] feat(smithy,smithy-web): UI disable/enable on AgentCard - packages/smithy: PATCH /api/agents/:id accepts disabled?: boolean. Toggling on a steward also (un)registers it with the running scheduler so behavior changes immediately without an sf serve restart. - smithy-web: useToggleAgentDisabled hook (mirrors useChangeAgentTriggers). - AgentCard: Disable/Enable menu item, dimmed card style + 'Disabled' badge when set, Start button hidden when disabled (the badge plus the menu item surface the state and the action). Disabled propagates through BaseAgentMetadata.disabled in the web types. --- apps/smithy-web/src/api/hooks/useAgents.ts | 21 ++++++++++ apps/smithy-web/src/api/types.ts | 2 + .../src/components/agent/AgentCard.tsx | 38 +++++++++++++++++-- packages/smithy/src/server/routes/agents.ts | 26 +++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/apps/smithy-web/src/api/hooks/useAgents.ts b/apps/smithy-web/src/api/hooks/useAgents.ts index 192ea8c8..eeace80c 100644 --- a/apps/smithy-web/src/api/hooks/useAgents.ts +++ b/apps/smithy-web/src/api/hooks/useAgents.ts @@ -490,6 +490,27 @@ export function useChangeAgentTriggers() { }); } +/** + * Hook to disable or enable an agent. + * Disabled agents stay in the list but are skipped by dispatch and the steward scheduler. + */ +export function useToggleAgentDisabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ agentId, disabled }) => { + return fetchApi(`/agents/${agentId}`, { + method: 'PATCH', + body: JSON.stringify({ disabled }), + }); + }, + onSuccess: (_, { agentId }) => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); + }, + }); +} + /** * Hook to delete an agent */ diff --git a/apps/smithy-web/src/api/types.ts b/apps/smithy-web/src/api/types.ts index 5c2f007d..2124244a 100644 --- a/apps/smithy-web/src/api/types.ts +++ b/apps/smithy-web/src/api/types.ts @@ -48,6 +48,8 @@ export interface BaseAgentMetadata { provider?: string; model?: string; executablePath?: string; + /** When true, the agent is parked: kept in the list but skipped by dispatch and the scheduler. */ + disabled?: boolean; } export interface DirectorMetadata extends BaseAgentMetadata { diff --git a/apps/smithy-web/src/components/agent/AgentCard.tsx b/apps/smithy-web/src/components/agent/AgentCard.tsx index e5e7e29c..972548be 100644 --- a/apps/smithy-web/src/components/agent/AgentCard.tsx +++ b/apps/smithy-web/src/components/agent/AgentCard.tsx @@ -5,12 +5,13 @@ */ import { useState, useRef, useEffect } from 'react'; -import { Play, Square, RefreshCw, Terminal, MoreVertical, Clock, GitBranch, Pencil, Inbox, Trash2, ArrowLeftRight, Settings, Zap } from 'lucide-react'; +import { Play, Square, RefreshCw, Terminal, MoreVertical, Clock, GitBranch, Pencil, Inbox, Trash2, ArrowLeftRight, Settings, Zap, Power, PowerOff } from 'lucide-react'; import type { Agent, WorkerMetadata, StewardMetadata, SessionStatus } from '../../api/types'; import { AgentStatusBadge } from './AgentStatusBadge'; import { AgentRoleBadge } from './AgentRoleBadge'; import { Tooltip } from '@stoneforge/ui'; import { useAgentInboxCount } from '../../api/hooks/useAgentInbox'; +import { useToggleAgentDisabled } from '../../api/hooks/useAgents'; import { AgentInboxDrawer } from './AgentInboxDrawer'; import { ChangeProviderDialog } from './ChangeProviderDialog'; import { getProviderLabel } from '../../lib/providers'; @@ -55,8 +56,10 @@ export function AgentCard({ const agentMeta = agent.metadata?.agent; const isRunning = activeSessionStatus === 'running' || activeSessionStatus === 'starting'; - const canStart = !isRunning && !isStarting; + const isDisabled = agentMeta?.disabled === true; + const canStart = !isRunning && !isStarting && !isDisabled; const canStop = isRunning && !isStopping; + const toggleDisabled = useToggleAgentDisabled(); // Close menu when clicking outside useEffect(() => { @@ -78,8 +81,9 @@ export function AgentCard({ return (
{/* Header */}
@@ -118,6 +122,15 @@ export function AgentCard({ {agentMeta.model} )} + {isDisabled && ( + + + Disabled + + )}
{agentMeta?.executablePath && (
)} +