diff --git a/README.md b/README.md index 94de0ee..19bf2ef 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,13 @@ and the first Claude reference-fleet path: and backlog inventory while refusing to invent missing ownership boundaries. - a redesigned public product site and read-only local Fleet Console backed by the same repository and runtime state rather than a separate dashboard model. +- identity-bound context-usage telemetry that separates measurements from + estimates and relates cost/cache behavior to verified work; +- validated system/session schedules with shell-free actions, authority, + retries, durable run evidence, and explicit approval for mutation. Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG -cutover, scheduling, richer measured context telemetry, and public deployment +cutover, persistent system-schedule launcher integration, and public deployment remain staged work. The canonical architecture and delivery plan is @@ -191,6 +195,28 @@ or mutation. Automation can obtain the same read-only view with: node bin/torch.mjs console snapshot --repo /path/to/project --json ``` +Runtime adapters and agents can report cache locality without conflating +provider measurements and estimates: + +```bash +torch context record --area backend --source provider-response \ + --measurement measured --cache-read 24000 --uncached-input 6000 \ + --cost-microusd 420000 --verified-items 1 --json +torch context report --area backend --json +``` + +Configured schedules remain machine-readable and authority-gated: + +```bash +torch schedules list --actor session-manager --json +torch schedules plan --id fleet-hygiene --actor session-manager --json +torch schedules run --id fleet-hygiene --actor session-manager --json +``` + +Mutating schedules additionally require `--yes`. Cron definitions are exposed +for an external system launcher; TORCH does not silently install persistent +timers. + ## Product principles - Git is required; GitHub is optional. diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 2587acf..542da55 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1061,6 +1061,13 @@ initializing a missing database. Context-locality output explicitly reports `unavailable` until runtime usage samples exist, preventing estimates from masquerading as measurements. +Identity-bound context telemetry now accepts provider measurements or explicit +estimates, never an unlabeled mixture. It records cached and uncached input, +cache creation and reads, compactions, resumed prompt size, micro-USD cost, +task/commit references, and verified item counts. Reports keep evidence classes +separate, derive cache-read ratio and cost per verified item, and warn when +usage has no verified outcome. + ## 22. Scheduling TORCH recognizes two schedule classes. @@ -1086,6 +1093,16 @@ Every schedule MUST declare: Schedules MUST be generated or validated from machine-readable configuration so launcher code and prose cannot silently disagree. +Implementation checkpoint (2026-09-27): installed configuration carries +validated system and session schedules with owner, trigger, behavior, +shell-free action, authority, retry, failure recipient, and source-of-truth +fields. The service plans without mutation, blocks session schedules while the +Session Manager is offline, requires explicit approval for mutating actions, +records durable run evidence, retries deterministically, and reports failures +through durable blocker messages. CLI and MCP surfaces share the service. +Cron definitions currently require an external launcher; persistent timer +installation remains open and will use the normal ownership manifest. + ## 23. Repository and forge operating modes ### 23.1 Local-only mode diff --git a/package.json b/package.json index 141bc62..b58e1b2 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,10 @@ "./mcp": "./src/mcp/tools.mjs", "./observability": "./src/observability/snapshot.mjs", "./resources": "./src/resources/service.mjs", + "./schedules": "./src/schedules/service.mjs", "./runtime/lifecycle": "./src/runtime/lifecycle.mjs", - "./self-host": "./src/self-host/service.mjs" + "./self-host": "./src/self-host/service.mjs", + "./telemetry/context": "./src/telemetry/context.mjs" }, "bin": { "torch": "./bin/torch.mjs", @@ -62,7 +64,9 @@ "src/observability/", "src/resources/", "src/runtime/", + "src/schedules/", "src/self-host/", + "src/telemetry/", "docs/PORTABLE_AGENT_FLEET_SPEC.md", "README.md", "LICENSE", @@ -74,8 +78,8 @@ "torch": "node bin/torch.mjs", "check": "node --check bin/torch.mjs && node --check src/cli.mjs && node --check src/mcp/server.mjs && node --check src/console/server.mjs && node --check site/app.js && node --check site/console.js", "site:serve": "node bin/torch.mjs console serve", - "format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix", - "lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs site/*.js test/fleet" + "format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix", + "lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet" }, "dependencies": { "@modelcontextprotocol/server": "^2.1.0", diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 3399ffe..f95f017 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -150,6 +150,18 @@ test_integrity_note: given: an installed Fleet without an initialized control-plane database when: the observation API and local HTTP console are requested then: fixed assets and structured state are served with restrictive headers while Git and machine-local state remain unchanged + - id: SCN-context-locality + given: measured provider usage and a separate heuristic estimate for one persistent Fleet identity + when: CLI and identity-bound MCP record and aggregate the samples + then: evidence classes stay separate, forged identities fail, and cache ratio plus cost per verified item are derived without calling zero-output usage efficient + - id: SCN-schedule-boundaries + given: system and session schedules with different owners, behavior, retries, and authority + when: schedules are planned and executed through a virtualized shell-free runner + then: offline sessions and unauthorized actors are blocked, mutation needs approval, failures retry exactly and notify durably, and each run is recorded + - id: SCN-cli-schedules + given: an installed Fleet with an active Session Manager and a read-only session schedule + when: public CLI commands list, plan, and run it + then: the same validated definition executes without shell interpretation and emits durable run evidence observable_outcomes: - Git status before and after read-only analysis - presence or absence of .torch and XDG-local project state @@ -160,6 +172,8 @@ test_integrity_note: - compatibility report counts and unchanged COMBATRIG Git status - HTTP status, content-security policy, fixed routes, and traversal refusal - unchanged Git status and absent database before and after console observation + - measured and estimated context groups, cache-read ratio, and cost per verified item + - schedule plan blockers, subprocess shell flag, retry count, and durable failure message - preservation of project source and modified owner configuration - SQLite state after process restart - CLI and MCP output over real process boundaries @@ -187,6 +201,7 @@ test_integrity_note: - external temporary Markdown specification with no provider or network calls - synthetic COMBATRIG layout in an isolated temporary Git repository - loopback-only ephemeral HTTP server and fixed static route map + - injected schedule executor, fixed clock, bounded retries, and no sleeps anti_cheat_rationale: prevents: - init that silently writes files @@ -218,6 +233,9 @@ test_integrity_note: - COMBATRIG compatibility inventing path ownership to force a green migration - a disconnected dashboard inventing state or creating it during observation - public copy claiming live qualification that only virtualized tests prove + - estimated token counts being presented as provider measurements + - low token use being called efficient without a verified outcome + - schedule prose diverging from executable definitions or mutating without approval relaxation: did_relax_any_assertion: false if_true_explain_spec_basis: "" diff --git a/src/cli.mjs b/src/cli.mjs index f89c678..443048e 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -23,6 +23,8 @@ import { planFleetDown, planFleetUp, startFleet, stopFleet } from './runtime/lif import { ResourceService } from './resources/service.mjs'; import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-host/service.mjs'; import { observeProject } from './observability/snapshot.mjs'; +import { ContextTelemetryService } from './telemetry/context.mjs'; +import { ScheduleService } from './schedules/service.mjs'; const HELP = `TORCH — portable agent fleet @@ -80,6 +82,12 @@ Usage: torch import combatrig --source [--output ] [--json] torch console snapshot [--repo ] [--json] torch console serve [--repo ] [--host
] [--port ] + torch context report [--area ] [--task ] [--commit ] [--measurement ] [--json] + torch context record --area --source --measurement [usage fields] [--json] + torch schedules list [--actor ] [--json] + torch schedules runs [--id ] [--json] + torch schedules plan --id --actor [--json] + torch schedules run --id --actor [--yes] [--json] torch doctor [--json] torch uninstall [--dry-run] [--purge] [--json] `; @@ -169,6 +177,7 @@ function runCandidateAcceptance(candidateRoot, spawn) { ['detach-uninstall', ['SCN-install-doctor-purge']], ['combatrig-compatibility', ['SCN-combatrig-import', 'SCN-cli-combatrig-import']], ['product-surface', ['SCN-product-site', 'SCN-console-readonly']], + ['operations-observability', ['SCN-context-locality', 'SCN-schedule-boundaries', 'SCN-cli-schedules']], ]); const scenarios = CANDIDATE_ACCEPTANCE_SCENARIOS.filter((scenario) => (evidenceByScenario.get(scenario) ?? []).every((marker) => testOutput.includes(marker))); @@ -558,6 +567,51 @@ export async function runCli(argv = process.argv.slice(2), { control.close(); } } + if (command === 'context') { + const operation = argv[1] ?? 'report'; + const control = openControlPlane({ repositoryRoot: repository.root, env }); + try { + const telemetry = new ContextTelemetryService({ controlPlane: control }); + if (operation === 'report') print(telemetry.report({ + areaId: optionValue(argv, '--area'), task: optionValue(argv, '--task'), + commit: optionValue(argv, '--commit'), measurement: optionValue(argv, '--measurement'), + }), { json }); + else if (operation === 'record') print(telemetry.record({ + areaId: optionValue(argv, '--area'), source: optionValue(argv, '--source'), + measurement: optionValue(argv, '--measurement'), runtime: optionValue(argv, '--runtime'), + runtimeSessionId: optionValue(argv, '--session'), task: optionValue(argv, '--task'), + commit: optionValue(argv, '--commit'), cachedInput: optionValue(argv, '--cached-input'), + uncachedInput: optionValue(argv, '--uncached-input'), cacheCreation: optionValue(argv, '--cache-creation'), + cacheRead: optionValue(argv, '--cache-read'), compactions: optionValue(argv, '--compactions'), + resumedPromptBytes: optionValue(argv, '--resumed-prompt-bytes'), costMicrousd: optionValue(argv, '--cost-microusd'), + verifiedItems: optionValue(argv, '--verified-items'), + }), { json }); + else throw new TorchError(`Unknown context operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); + return 0; + } finally { + control.close(); + } + } + if (command === 'schedules') { + const operation = argv[1] ?? 'list'; + const control = openControlPlane({ repositoryRoot: repository.root, env }); + try { + const schedules = new ScheduleService({ repositoryRoot: repository.root, controlPlane: control }); + const scheduleId = optionValue(argv, '--id'); + const actorId = optionValue(argv, '--actor') ?? 'owner'; + if (operation === 'list') print({ schedules: schedules.list({ actorId }) }, { json }); + else if (operation === 'runs') print({ runs: schedules.runs({ scheduleId }) }, { json }); + else if (operation === 'plan') print(schedules.plan({ scheduleId, actorId }), { json }); + else if (operation === 'run') { + const result = schedules.run({ scheduleId, actorId, approved: argv.includes('--yes') }); + print(result, { json }); + return result.result === 'succeeded' ? 0 : 1; + } else throw new TorchError(`Unknown schedules operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); + return 0; + } finally { + control.close(); + } + } if (command === 'checks') { const operation = argv[1] ?? 'list'; const control = openControlPlane({ repositoryRoot: repository.root, env }); diff --git a/src/importers/combatrig.mjs b/src/importers/combatrig.mjs index 089d0cc..9b3823d 100644 --- a/src/importers/combatrig.mjs +++ b/src/importers/combatrig.mjs @@ -139,6 +139,22 @@ export function analyzeCombatrigFleet({ repository } = {}) { const checks = packageChecks(root); const operationsPath = join(root, 'docs', 'agents', 'OPERATIONS.md'); const operations = existsSync(operationsPath) ? readFileSync(operationsPath, 'utf8') : ''; + const schedules = [ + { + id: 'release', title: 'Owner-approved release', owner: 'owner', lifetime: 'system', + trigger: { type: 'cron', expression: '0 8,16 * * *' }, behavior: 'mutating', + action: { type: 'command', command: './tools/dev/release.sh', args: [] }, + required_authority: ['owner'], retry: { max_attempts: 1 }, failure_recipient: 'session-manager', + source_of_truth: 'docs/agents/OPERATIONS.md', detected: operations.includes('0 8,16 * * *'), + }, + { + id: 'dispatcher-hygiene', title: 'Session fleet hygiene', owner: 'session-manager', lifetime: 'session', + trigger: { type: 'interval', seconds: 900 }, behavior: 'read-only', + action: { type: 'command', command: 'torch', args: ['doctor', '--json'] }, + required_authority: ['session-manager'], retry: { max_attempts: 1 }, failure_recipient: 'session-manager', + source_of_truth: 'docs/agents/OPERATIONS.md', detected: operations.includes('Session-local schedules'), + }, + ]; return { schema: 'torch.dev/domain-proposal/v1alpha1', generatedAt: new Date().toISOString(), @@ -162,16 +178,14 @@ export function analyzeCombatrigFleet({ repository } = {}) { id: 'browser-renderer', capacity: gateCapacity(root), queue: 'fifo', max_hold_seconds: 10800, evidence: 'tools/test/serve.mjs', }], + schedules, architecture: { components: [], dependencies: [], sharedSurfaces: [], verificationSurfaces: [], operationalSurfaces: [] }, compatibility: { source: 'combatrig-portable-fleet', sourceRoster: 'docs/agents/roster.json', areas: { total: converted.length, domains: domains.length, managerLegacyId: manager?.legacy_id ?? null }, backlog: backlogInventory(root), checkCatalog: checks.catalog, - schedules: [ - { id: 'release', lifetime: 'system', authority: 'owner', expression: '0 8,16 * * *', command: 'tools/dev/release.sh', detected: operations.includes('0 8,16 * * *') }, - { id: 'dispatcher-hygiene', lifetime: 'session', authority: 'session-manager', detected: operations.includes('Session-local schedules') }, - ], + schedules, release: { provider: 'custom-command', authority: 'owner', command: 'tools/dev/release.sh' }, worktrees: converted.map((area) => ({ areaId: area.id, ...area.worktree_status })), migrationGaps: gaps, diff --git a/src/kernel/domains.mjs b/src/kernel/domains.mjs index ab27ced..fe2f543 100644 --- a/src/kernel/domains.mjs +++ b/src/kernel/domains.mjs @@ -314,6 +314,7 @@ export function proposeDomains({ repository, analysis }) { resources: analysis.inventory.scarceResources.map((id) => ({ id, capacity: 1, queue: 'fifo', max_hold_seconds: 3600, })), + schedules: [], architecture: graph, }; } @@ -360,6 +361,14 @@ export function validateApprovedProposal({ proposal, repository }) { for (const collision of proposal?.collisions ?? []) { if (!collision.resolution?.strategy) problems.push(`collision ${collision.domains?.join('/')} has no resolution`); } + for (const schedule of proposal?.schedules ?? []) { + if (!schedule.id || !['system', 'session'].includes(schedule.lifetime) + || !['read-only', 'mutating'].includes(schedule.behavior) + || !schedule.owner || !schedule.trigger || !schedule.action + || !schedule.required_authority?.length || !schedule.source_of_truth) { + problems.push(`schedule ${schedule.id ?? ''} is incomplete`); + } + } if (proposal?.session_manager?.worktree_name !== undefined && proposal.session_manager.worktree_name !== null && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(proposal.session_manager.worktree_name)) { diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 7dfbf5b..6f172b0 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -47,6 +47,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { }, checks: proposal.checks ?? [], resources: proposal.resources ?? [], + schedules: proposal.schedules ?? [], integration: { provider: 'torch', target: repository.branch || 'main', require_current_main: true, required_checks: (proposal.checks ?? []).map((check) => check.id), diff --git a/src/mcp/server.mjs b/src/mcp/server.mjs index 3b3bd98..b261d59 100755 --- a/src/mcp/server.mjs +++ b/src/mcp/server.mjs @@ -7,6 +7,8 @@ import { IntegrationService } from '../integration/service.mjs'; import { openControlPlane } from '../control-plane/service.mjs'; import { ResourceService } from '../resources/service.mjs'; import { createTorchMcpServer } from './tools.mjs'; +import { ContextTelemetryService } from '../telemetry/context.mjs'; +import { ScheduleService } from '../schedules/service.mjs'; function optionValue(argv, name) { const direct = argv.find((argument) => argument.startsWith(`${name}=`)); @@ -25,8 +27,10 @@ const integrationService = new IntegrationService({ repositoryRoot, controlPlane const backlogService = new BacklogService({ repositoryRoot, controlPlane, integrationLookup: (requestId) => integrationService.get(requestId), }); +const contextTelemetryService = new ContextTelemetryService({ controlPlane }); +const scheduleService = new ScheduleService({ repositoryRoot, controlPlane }); const handle = serveStdio(() => createTorchMcpServer(controlPlane, { - actorId, backlogService, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, }), { onerror: (error) => console.error(`TORCH MCP: ${error.message}`), }); diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index 8ae8790..fae4433 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -47,6 +47,12 @@ export const TORCH_MCP_TOOL_NAMES = Object.freeze([ 'torch_authorize_integration', 'torch_plan_integration_landing', 'torch_land_integration', + 'torch_record_context_usage', + 'torch_context_report', + 'torch_list_schedules', + 'torch_schedule_runs', + 'torch_plan_schedule', + 'torch_run_schedule', ]); function claimedIdentity(controlPlane, actorId, claim, field) { @@ -65,7 +71,7 @@ function claimedIdentity(controlPlane, actorId, claim, field) { } export function createTorchToolset(controlPlane, { - actorId, backlogService, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, } = {}) { const tools = new Map([ ['torch_identity', { @@ -330,6 +336,61 @@ export function createTorchToolset(controlPlane, { }), }); } + if (contextTelemetryService) { + tools.set('torch_record_context_usage', { + description: 'Record measured or explicitly estimated context usage for the bound Fleet identity and its verified outcomes.', + schema: { + area_id: z.string().min(1).optional(), source: z.string().min(1), measurement: z.enum(['measured', 'estimated']), + runtime: z.string().min(1).optional(), runtime_session_id: z.string().min(1).optional(), + task: z.string().min(1).optional(), commit: z.string().min(1).optional(), + cached_input: z.number().int().nonnegative().optional(), uncached_input: z.number().int().nonnegative().optional(), + cache_creation: z.number().int().nonnegative().optional(), cache_read: z.number().int().nonnegative().optional(), + compactions: z.number().int().nonnegative().optional(), resumed_prompt_bytes: z.number().int().nonnegative().optional(), + cost_microusd: z.number().int().nonnegative().optional(), verified_items: z.number().int().nonnegative().optional(), + }, + invoke: ({ + area_id: areaId, runtime_session_id: runtimeSessionId, cached_input: cachedInput, + uncached_input: uncachedInput, cache_creation: cacheCreation, cache_read: cacheRead, + resumed_prompt_bytes: resumedPromptBytes, cost_microusd: costMicrousd, + verified_items: verifiedItems, ...input + }) => contextTelemetryService.record({ + ...input, runtimeSessionId, cachedInput, uncachedInput, cacheCreation, cacheRead, + resumedPromptBytes, costMicrousd, verifiedItems, + areaId: claimedIdentity(controlPlane, actorId, areaId, 'area_id'), + }), + }); + tools.set('torch_context_report', { + description: 'Aggregate context locality by Fleet identity while keeping measured and estimated evidence separate.', + schema: { + area_id: z.string().min(1).optional(), task: z.string().min(1).optional(), + commit: z.string().min(1).optional(), measurement: z.enum(['measured', 'estimated']).optional(), + }, + invoke: ({ area_id: areaId, ...input }) => contextTelemetryService.report({ + ...input, areaId: areaId ? claimedIdentity(controlPlane, actorId, areaId, 'area_id') : actorId, + }), + }); + } + if (scheduleService) { + tools.set('torch_list_schedules', { + description: 'List validated schedules and their current authority, lifetime, and due status.', + schema: {}, invoke: () => ({ schedules: scheduleService.list({ actorId }) }), + }); + tools.set('torch_schedule_runs', { + description: 'List durable run evidence for configured schedules.', + schema: { schedule_id: z.string().min(1).optional(), limit: z.number().int().min(1).max(1000).optional() }, + invoke: ({ schedule_id: scheduleId, limit }) => ({ runs: scheduleService.runs({ scheduleId, limit }) }), + }); + tools.set('torch_plan_schedule', { + description: 'Preview a configured schedule and its blockers without executing it.', + schema: { schedule_id: z.string().min(1) }, + invoke: ({ schedule_id: scheduleId }) => scheduleService.plan({ scheduleId, actorId }), + }); + tools.set('torch_run_schedule', { + description: 'Run a read-only configured schedule as the bound Fleet identity. Mutating schedules require owner CLI approval.', + schema: { schedule_id: z.string().min(1) }, + invoke: ({ schedule_id: scheduleId }) => scheduleService.run({ scheduleId, actorId, approved: false }), + }); + } return tools; } @@ -341,7 +402,7 @@ export function callTorchTool(controlPlane, name, input = {}, options = {}) { } export function createTorchMcpServer(controlPlane, { - actorId, backlogService, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, } = {}) { controlPlane.assertIdentity(actorId); const server = new McpServer( @@ -351,7 +412,7 @@ export function createTorchMcpServer(controlPlane, { }, ); for (const [name, tool] of createTorchToolset(controlPlane, { - actorId, backlogService, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, })) { server.registerTool(name, { description: tool.description, inputSchema: tool.schema }, async (input) => { try { diff --git a/src/observability/snapshot.mjs b/src/observability/snapshot.mjs index 474d6e5..2f7bf67 100644 --- a/src/observability/snapshot.mjs +++ b/src/observability/snapshot.mjs @@ -141,13 +141,18 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => `); if (hasTable(database, 'context_usage')) { const samples = database.prepare(` - SELECT area_id AS areaId, SUM(cached_input) AS cachedInput, SUM(uncached_input) AS uncachedInput, + SELECT area_id AS areaId, measurement, SUM(cached_input) AS cachedInput, SUM(uncached_input) AS uncachedInput, SUM(cache_creation) AS cacheCreation, SUM(cache_read) AS cacheRead, SUM(compactions) AS compactions, SUM(resumed_prompt_bytes) AS resumedPromptBytes, SUM(cost_microusd) AS costMicrousd, SUM(verified_items) AS verifiedItems - FROM context_usage GROUP BY area_id ORDER BY area_id + FROM context_usage GROUP BY area_id, measurement ORDER BY area_id, measurement `).all(); - result.contextLocality = { measured: true, status: 'measured', samples }; + const measured = samples.some((sample) => sample.measurement === 'measured'); + result.contextLocality = { + measured, status: measured ? 'measured' : 'estimated-only', samples, + warning: samples.some((sample) => sample.verifiedItems === 0) + ? 'Usage without verified outcomes cannot establish efficiency.' : null, + }; } } finally { database.close(); diff --git a/src/schedules/service.mjs b/src/schedules/service.mjs new file mode 100644 index 0000000..117a300 --- /dev/null +++ b/src/schedules/service.mjs @@ -0,0 +1,178 @@ +import { randomUUID } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { loadFleetDefinition } from '../kernel/worktrees.mjs'; +import { TorchError } from '../kernel/errors.mjs'; + +function text(value, field) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`Schedule ${field} is required`, { code: 'SCHEDULE_CONFIG_INVALID', details: { field } }); + } + return value.trim(); +} + +function validate(definition) { + const id = text(definition?.id, 'id'); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw new TorchError(`Invalid schedule id: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + if (!['system', 'session'].includes(definition.lifetime)) throw new TorchError(`Invalid schedule lifetime: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + if (!['read-only', 'mutating'].includes(definition.behavior)) throw new TorchError(`Invalid schedule behavior: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + if (!Array.isArray(definition.required_authority) || !definition.required_authority.length) { + throw new TorchError(`Schedule has no required authority: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + } + if (!definition.trigger || !['manual', 'interval', 'cron'].includes(definition.trigger.type)) { + throw new TorchError(`Invalid schedule trigger: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + } + if (definition.trigger.type === 'interval' + && (!Number.isInteger(definition.trigger.seconds) || definition.trigger.seconds < 1)) { + throw new TorchError(`Invalid schedule interval: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + } + if (definition.trigger.type === 'cron' + && (typeof definition.trigger.expression !== 'string' || definition.trigger.expression.trim().split(/\s+/).length !== 5)) { + throw new TorchError(`Invalid schedule cron expression: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + } + if (definition.action?.type !== 'command' || typeof definition.action.command !== 'string' + || !Array.isArray(definition.action.args) || definition.action.args.some((arg) => typeof arg !== 'string')) { + throw new TorchError(`Schedule action must be a shell-free command: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + } + const attempts = definition.retry?.max_attempts ?? 1; + if (!Number.isInteger(attempts) || attempts < 1 || attempts > 10) { + throw new TorchError(`Invalid schedule retry policy: ${id}`, { code: 'SCHEDULE_CONFIG_INVALID' }); + } + text(definition.owner, 'owner'); + text(definition.source_of_truth, 'source_of_truth'); + return { ...definition, retry: { max_attempts: attempts } }; +} + +function bounded(value) { + const content = value?.toString() ?? ''; + return content.length > 65_536 ? `${content.slice(0, 65_536)}\n[truncated]` : content; +} + +export class ScheduleService { + constructor({ + repositoryRoot, controlPlane, + executor = (command, args, options) => spawnSync(command, args, options), + clock = () => new Date(), idFactory = randomUUID, + } = {}) { + this.repositoryRoot = repositoryRoot; + this.controlPlane = controlPlane; + this.executor = executor; + this.clock = clock; + this.idFactory = idFactory; + const { config } = loadFleetDefinition(repositoryRoot); + this.definitions = new Map((config.schedules ?? []).map((definition) => { + const normalized = validate(definition); + return [normalized.id, normalized]; + })); + } + + #initialize() { + this.controlPlane.database.exec(` + CREATE TABLE IF NOT EXISTS schedule_runs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + schedule_id TEXT NOT NULL, + actor_id TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT NOT NULL, + attempts INTEGER NOT NULL, + exit_status INTEGER, + result TEXT NOT NULL, + stdout TEXT, + stderr TEXT + ); + CREATE INDEX IF NOT EXISTS schedule_runs_schedule_time + ON schedule_runs(schedule_id, started_at, id); + `); + } + + #definition(id) { + const definition = this.definitions.get(id); + if (!definition) throw new TorchError(`Unknown schedule: ${id}`, { code: 'SCHEDULE_NOT_FOUND' }); + return definition; + } + + runs({ scheduleId, limit = 100 } = {}) { + const table = this.controlPlane.database.prepare(` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schedule_runs' + `).get(); + if (!table) return []; + const safeLimit = Number.isInteger(limit) && limit > 0 && limit <= 1000 ? limit : 100; + const rows = scheduleId + ? this.controlPlane.database.prepare(`SELECT * FROM schedule_runs WHERE schedule_id = ? ORDER BY started_at DESC, id DESC LIMIT ?`).all(scheduleId, safeLimit) + : this.controlPlane.database.prepare(`SELECT * FROM schedule_runs ORDER BY started_at DESC, id DESC LIMIT ?`).all(safeLimit); + return rows.map((row) => ({ + id: row.id, scheduleId: row.schedule_id, actorId: row.actor_id, + startedAt: row.started_at, finishedAt: row.finished_at, attempts: row.attempts, + exitStatus: row.exit_status, result: row.result, stdout: row.stdout, stderr: row.stderr, + })); + } + + plan({ scheduleId, actorId, at = this.clock() } = {}) { + const definition = this.#definition(scheduleId); + const blockers = []; + if (!definition.required_authority.includes(actorId)) blockers.push('actor-not-authorized'); + if (actorId !== 'owner') { + try { this.controlPlane.assertIdentity(actorId); } catch { blockers.push('actor-not-a-fleet-identity'); } + } + if (definition.lifetime === 'session') { + const manager = this.controlPlane.identity('session-manager'); + if (manager.state === 'offline') blockers.push('session-manager-offline'); + } + const lastRun = this.runs({ scheduleId, limit: 1 })[0] ?? null; + let due = definition.trigger.type === 'manual' ? null : !lastRun; + let nextAt = null; + if (definition.trigger.type === 'interval' && lastRun) { + nextAt = new Date(Date.parse(lastRun.startedAt) + definition.trigger.seconds * 1000).toISOString(); + due = at.getTime() >= Date.parse(nextAt); + } + if (definition.trigger.type === 'cron') due = 'external-launcher'; + return { + schedule: definition, actorId, due, nextAt, blockers, + canRun: blockers.length === 0, mutationPerformed: false, + }; + } + + run({ scheduleId, actorId, approved = false } = {}) { + const plan = this.plan({ scheduleId, actorId }); + if (!plan.canRun) throw new TorchError(`Schedule cannot run: ${scheduleId}`, { code: 'SCHEDULE_BLOCKED', details: plan.blockers }); + if (plan.schedule.behavior === 'mutating' && approved !== true) { + throw new TorchError('Mutating schedule requires explicit approval', { code: 'APPROVAL_REQUIRED' }); + } + const startedAt = this.clock().toISOString(); + let execution; + let attempts = 0; + do { + attempts += 1; + execution = this.executor(plan.schedule.action.command, plan.schedule.action.args, { + cwd: this.repositoryRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: false, + }); + } while ((execution.status !== 0 || execution.error) && attempts < plan.schedule.retry.max_attempts); + const succeeded = execution.status === 0 && !execution.error && !execution.signal; + const run = { + id: this.idFactory(), scheduleId, actorId, startedAt, finishedAt: this.clock().toISOString(), + attempts, exitStatus: execution.status ?? null, result: succeeded ? 'succeeded' : 'failed', + stdout: bounded(execution.stdout), stderr: bounded(execution.stderr ?? execution.error?.message), + }; + this.#initialize(); + this.controlPlane.database.prepare(` + INSERT INTO schedule_runs ( + id, project_id, schedule_id, actor_id, started_at, finished_at, + attempts, exit_status, result, stdout, stderr + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + run.id, this.controlPlane.projectId, run.scheduleId, run.actorId, run.startedAt, + run.finishedAt, run.attempts, run.exitStatus, run.result, run.stdout, run.stderr, + ); + if (!succeeded && plan.schedule.failure_recipient && actorId !== 'owner') { + this.controlPlane.sendMessage({ + sender: actorId, recipient: plan.schedule.failure_recipient, kind: 'blocker', + body: `Schedule ${scheduleId} failed after ${attempts} attempt${attempts === 1 ? '' : 's'}.`, + }); + } + return { ...run, mutationPerformed: true }; + } + + list({ actorId } = {}) { + return [...this.definitions.values()].map((definition) => this.plan({ scheduleId: definition.id, actorId })); + } +} diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 34cd18f..ba18fff 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -22,6 +22,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'detach-uninstall', 'combatrig-compatibility', 'product-surface', + 'operations-observability', ]); function json(path) { diff --git a/src/telemetry/context.mjs b/src/telemetry/context.mjs new file mode 100644 index 0000000..04c9ee2 --- /dev/null +++ b/src/telemetry/context.mjs @@ -0,0 +1,141 @@ +import { randomUUID } from 'node:crypto'; +import { TorchError } from '../kernel/errors.mjs'; + +const METRICS = [ + 'cachedInput', 'uncachedInput', 'cacheCreation', 'cacheRead', + 'compactions', 'resumedPromptBytes', 'costMicrousd', 'verifiedItems', +]; + +function nonnegativeInteger(value, field) { + const number = value === undefined || value === null || value === '' ? 0 : Number(value); + if (!Number.isSafeInteger(number) || number < 0) { + throw new TorchError(`${field} must be a nonnegative safe integer`, { + code: 'CONTEXT_METRIC_INVALID', details: { field, value }, + }); + } + return number; +} + +function optionalText(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function hasTable(database) { + return Boolean(database.prepare(` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'context_usage' + `).get()); +} + +export class ContextTelemetryService { + constructor({ controlPlane, clock = () => new Date(), idFactory = randomUUID } = {}) { + this.controlPlane = controlPlane; + this.clock = clock; + this.idFactory = idFactory; + } + + #initialize() { + this.controlPlane.database.exec(` + CREATE TABLE IF NOT EXISTS context_usage ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + area_id TEXT NOT NULL, + runtime TEXT, + runtime_session_id TEXT, + task_ref TEXT, + commit_sha TEXT, + measurement TEXT NOT NULL, + source TEXT NOT NULL, + cached_input INTEGER NOT NULL, + uncached_input INTEGER NOT NULL, + cache_creation INTEGER NOT NULL, + cache_read INTEGER NOT NULL, + compactions INTEGER NOT NULL, + resumed_prompt_bytes INTEGER NOT NULL, + cost_microusd INTEGER NOT NULL, + verified_items INTEGER NOT NULL, + recorded_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS context_usage_area_time + ON context_usage(area_id, recorded_at, id); + CREATE INDEX IF NOT EXISTS context_usage_task + ON context_usage(task_ref, area_id); + `); + } + + record(input = {}) { + const areaId = this.controlPlane.assertIdentity(input.areaId); + if (!['measured', 'estimated'].includes(input.measurement)) { + throw new TorchError('Context usage must declare measured or estimated evidence', { + code: 'CONTEXT_MEASUREMENT_INVALID', details: { measurement: input.measurement }, + }); + } + const source = optionalText(input.source); + if (!source) throw new TorchError('Context usage requires an evidence source', { code: 'CONTEXT_SOURCE_REQUIRED' }); + const values = Object.fromEntries(METRICS.map((field) => [field, nonnegativeInteger(input[field], field)])); + const sample = { + id: this.idFactory(), projectId: this.controlPlane.projectId, areaId, + runtime: optionalText(input.runtime), runtimeSessionId: optionalText(input.runtimeSessionId), + task: optionalText(input.task), commit: optionalText(input.commit), + measurement: input.measurement, source, ...values, recordedAt: this.clock().toISOString(), + }; + this.#initialize(); + this.controlPlane.database.prepare(` + INSERT INTO context_usage ( + id, project_id, area_id, runtime, runtime_session_id, task_ref, commit_sha, + measurement, source, cached_input, uncached_input, cache_creation, cache_read, + compactions, resumed_prompt_bytes, cost_microusd, verified_items, recorded_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + sample.id, sample.projectId, sample.areaId, sample.runtime, sample.runtimeSessionId, + sample.task, sample.commit, sample.measurement, sample.source, sample.cachedInput, + sample.uncachedInput, sample.cacheCreation, sample.cacheRead, sample.compactions, + sample.resumedPromptBytes, sample.costMicrousd, sample.verifiedItems, sample.recordedAt, + ); + this.controlPlane.audit({ + actorId: areaId, operation: 'context.record', entityType: 'context-usage', entityId: sample.id, + details: { measurement: sample.measurement, source: sample.source, task: sample.task, commit: sample.commit }, + }); + return sample; + } + + report({ areaId, task, commit, measurement } = {}) { + if (areaId) this.controlPlane.assertIdentity(areaId); + if (measurement && !['measured', 'estimated'].includes(measurement)) { + throw new TorchError('Unknown context measurement class', { code: 'CONTEXT_MEASUREMENT_INVALID' }); + } + if (!hasTable(this.controlPlane.database)) { + return { measured: false, samples: 0, groups: [], warning: 'No context usage has been reported.' }; + } + const clauses = []; + const parameters = []; + for (const [column, value] of [['area_id', areaId], ['task_ref', task], ['commit_sha', commit], ['measurement', measurement]]) { + if (value) { clauses.push(`${column} = ?`); parameters.push(value); } + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const groups = this.controlPlane.database.prepare(` + SELECT area_id AS areaId, measurement, + COUNT(*) AS samples, + SUM(cached_input) AS cachedInput, + SUM(uncached_input) AS uncachedInput, + SUM(cache_creation) AS cacheCreation, + SUM(cache_read) AS cacheRead, + SUM(compactions) AS compactions, + SUM(resumed_prompt_bytes) AS resumedPromptBytes, + SUM(cost_microusd) AS costMicrousd, + SUM(verified_items) AS verifiedItems + FROM context_usage ${where} + GROUP BY area_id, measurement ORDER BY area_id, measurement + `).all(...parameters).map((group) => ({ + ...group, + cacheReadRatio: group.cacheRead + group.uncachedInput > 0 + ? group.cacheRead / (group.cacheRead + group.uncachedInput) : null, + costPerVerifiedItemMicrousd: group.verifiedItems > 0 ? group.costMicrousd / group.verifiedItems : null, + })); + return { + measured: groups.some((group) => group.measurement === 'measured'), + samples: groups.reduce((total, group) => total + group.samples, 0), groups, + warning: groups.some((group) => group.verifiedItems === 0) + ? 'Some usage has no verified outcome; lower context volume alone is not success.' : null, + }; + } +} diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index fbf0bcc..feea222 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -38,6 +38,7 @@ function releaseCandidate(root, version) { 'SCN-fleet-fresh-resume', 'SCN-install-doctor-purge', 'SCN-combatrig-import', 'SCN-cli-combatrig-import', 'SCN-product-site', 'SCN-console-readonly', + 'SCN-context-locality', 'SCN-schedule-boundaries', 'SCN-cli-schedules', ].join('\n'))});\n`); writeFileSync(join(candidateRoot, 'package.json'), `${JSON.stringify({ name: 'torch-agent-fleet', version, bin: { torch: 'bin/torch.mjs' }, diff --git a/test/fleet/context-telemetry.test.mjs b/test/fleet/context-telemetry.test.mjs new file mode 100644 index 0000000..073111a --- /dev/null +++ b/test/fleet/context-telemetry.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { callTorchTool } from '../../src/mcp/tools.mjs'; +import { observeProject } from '../../src/observability/snapshot.mjs'; +import { ContextTelemetryService } from '../../src/telemetry/context.mjs'; + +const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-context-')); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-context-state-')) }; + installProject({ repository, proposal, env, projectId: 'context-fixture' }); + return { root, env, worker: proposal.domains[0].id }; +} + +test('SCN-context-locality: usage evidence stays identity-bound, separates estimates, and prices verified outcomes', () => { + const context = fixture(); + const recorded = spawnSync(process.execPath, [ + CLI, 'context', 'record', '--area', context.worker, '--source', 'provider-response', + '--measurement', 'measured', '--runtime', 'codex', '--session', 'thread-1', '--task', 'TASK-1', + '--cached-input', '800', '--uncached-input', '200', '--cache-read', '800', + '--cost-microusd', '500000', '--verified-items', '2', '--json', + ], { cwd: context.root, env: context.env, encoding: 'utf8' }); + assert.equal(recorded.status, 0, recorded.stderr || recorded.stdout); + assert.equal(JSON.parse(recorded.stdout).measurement, 'measured'); + + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + const telemetry = new ContextTelemetryService({ + controlPlane: control, idFactory: () => 'estimated-sample', + clock: () => new Date('2026-09-27T01:00:00Z'), + }); + const estimated = callTorchTool(control, 'torch_record_context_usage', { + source: 'prompt-size-heuristic', measurement: 'estimated', cached_input: 100, + uncached_input: 400, cost_microusd: 100000, verified_items: 0, + }, { actorId: context.worker, contextTelemetryService: telemetry }); + assert.equal(estimated.areaId, context.worker); + assert.throws(() => callTorchTool(control, 'torch_record_context_usage', { + area_id: 'session-manager', source: 'forged', measurement: 'measured', + }, { actorId: context.worker, contextTelemetryService: telemetry }), + (error) => error.code === 'FLEET_IDENTITY_MISMATCH'); + + const report = telemetry.report({ areaId: context.worker }); + assert.equal(report.measured, true); + assert.equal(report.samples, 2); + assert.deepEqual(report.groups.map((group) => group.measurement), ['estimated', 'measured']); + const measured = report.groups.find((group) => group.measurement === 'measured'); + assert.equal(measured.cacheReadRatio, 0.8); + assert.equal(measured.costPerVerifiedItemMicrousd, 250000); + assert.match(report.warning, /no verified outcome/i); + control.close(); + + const snapshot = observeProject({ repositoryRoot: context.root, env: context.env }); + assert.equal(snapshot.contextLocality.measured, true); + assert.deepEqual(snapshot.contextLocality.samples.map((sample) => sample.measurement), ['estimated', 'measured']); +}); diff --git a/test/fleet/schedules.test.mjs b/test/fleet/schedules.test.mjs new file mode 100644 index 0000000..d8548b1 --- /dev/null +++ b/test/fleet/schedules.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { ScheduleService } from '../../src/schedules/service.mjs'; + +const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-schedules-')); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + const worker = proposal.domains[0].id; + proposal.schedules = [ + { + id: 'fleet-hygiene', title: 'Fleet hygiene', owner: 'session-manager', lifetime: 'session', + trigger: { type: 'interval', seconds: 900 }, behavior: 'read-only', + action: { type: 'command', command: process.execPath, args: ['-e', 'process.stdout.write("healthy")'] }, + required_authority: ['session-manager'], retry: { max_attempts: 1 }, + failure_recipient: 'session-manager', source_of_truth: 'fixture proposal', + }, + { + id: 'domain-audit', title: 'Domain audit', owner: worker, lifetime: 'session', + trigger: { type: 'manual' }, behavior: 'read-only', + action: { type: 'command', command: 'audit-fixture', args: [] }, + required_authority: [worker], retry: { max_attempts: 2 }, + failure_recipient: 'session-manager', source_of_truth: 'fixture proposal', + }, + { + id: 'release', title: 'Release', owner: 'owner', lifetime: 'system', + trigger: { type: 'cron', expression: '0 8,16 * * *' }, behavior: 'mutating', + action: { type: 'command', command: 'release-fixture', args: [] }, + required_authority: ['owner'], retry: { max_attempts: 1 }, + failure_recipient: 'session-manager', source_of_truth: 'fixture proposal', + }, + ]; + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-state-')) }; + installProject({ repository, proposal, env, projectId: 'schedule-fixture' }); + return { root, env, worker }; +} + +test('SCN-schedule-boundaries: validated lifetimes, authority, retries, and approval govern shell-free actions', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + const calls = []; + const service = new ScheduleService({ + repositoryRoot: context.root, controlPlane: control, + clock: () => new Date('2026-09-27T12:00:00Z'), idFactory: (() => { let id = 0; return () => `run-${++id}`; })(), + executor: (command, args, options) => { + calls.push({ command, args, options }); + return command === 'audit-fixture' + ? { status: 1, stdout: '', stderr: 'audit failed' } + : { status: 0, stdout: 'ok', stderr: '' }; + }, + }); + assert.deepEqual(service.plan({ scheduleId: 'fleet-hygiene', actorId: 'session-manager' }).blockers, ['session-manager-offline']); + control.reportStatus({ areaId: 'session-manager', state: 'idle', summary: 'Scheduling.' }); + const hygiene = service.run({ scheduleId: 'fleet-hygiene', actorId: 'session-manager' }); + assert.equal(hygiene.result, 'succeeded'); + assert.equal(calls[0].options.shell, false); + + const failed = service.run({ scheduleId: 'domain-audit', actorId: context.worker }); + assert.equal(failed.result, 'failed'); + assert.equal(failed.attempts, 2); + assert.equal(control.readMessages({ recipient: 'session-manager' }).at(-1).kind, 'blocker'); + assert.throws(() => service.run({ scheduleId: 'release', actorId: 'owner' }), + (error) => error.code === 'APPROVAL_REQUIRED'); + assert.equal(service.run({ scheduleId: 'release', actorId: 'owner', approved: true }).result, 'succeeded'); + assert.equal(service.runs({}).length, 3); + control.close(); +}); + +test('SCN-cli-schedules: public commands list, plan, and run a read-only session schedule', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + control.reportStatus({ areaId: 'session-manager', state: 'idle', summary: 'Ready.' }); + control.close(); + const run = (args) => spawnSync(process.execPath, [CLI, ...args, '--json'], { + cwd: context.root, env: context.env, encoding: 'utf8', + }); + const listed = run(['schedules', 'list', '--actor', 'session-manager']); + assert.equal(listed.status, 0, listed.stderr || listed.stdout); + assert.equal(JSON.parse(listed.stdout).schedules.length, 3); + const planned = JSON.parse(run(['schedules', 'plan', '--id', 'fleet-hygiene', '--actor', 'session-manager']).stdout); + assert.equal(planned.canRun, true); + const executed = run(['schedules', 'run', '--id', 'fleet-hygiene', '--actor', 'session-manager']); + assert.equal(executed.status, 0, executed.stderr || executed.stdout); + assert.equal(JSON.parse(executed.stdout).stdout, 'healthy'); +});