diff --git a/README.md b/README.md index fac9441a5..a06c3bbf8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ # 🏢 Agent Office +Named teams can be loaded per project from `.agent-office/worker-profiles.json`. +The **Team** button shows the full roster count. Available and held members appear +in the 3D office before they have a task; only assigning a task starts a CLI worker. +An optional finite [autopilot supervisor](docs/agents.md#optional-finite-autopilot) +can ask an idle coordinator to select the next eligible issue after the team finishes. +**Menu → Named workers** preserves each person's name, role, exact model and +effort, and keeps held profiles visible without starting them. See [Choosing an agent](docs/agents.md). + **A 3D office your team shares with its coding agents.** Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, diff --git a/bin/autopilot/policy.mjs b/bin/autopilot/policy.mjs new file mode 100644 index 000000000..5fa7bf59f --- /dev/null +++ b/bin/autopilot/policy.mjs @@ -0,0 +1,42 @@ +import { createHash } from 'node:crypto'; + +export const BUSY = new Set(['starting', 'working', 'needs_input']); +const HELD = new Set(['execution:held', 'no-agent', 'status:held', 'status:paused', 'status:blocked', 'status:done', 'status:archived', 'status:review', 'status:in-progress']); + +export function fingerprint(issue) { + return createHash('sha256').update(JSON.stringify([issue.number, issue.updatedAt, issue.body, issue.labels.map(label => label.name).sort()])).digest('hex'); +} + +/** Only a ready task can execute. Other unheld issues may be assessed for a task contract. */ +export function candidates(issues, { owner, reserved = [], completed = [], inspected = {} }) { + const reservedIds = new Set(reserved); + const completedRuns = new Set(completed); + return issues.filter(issue => { + const labels = new Set(issue.labels.map(label => label.name.toLowerCase())); + if (issue.author?.login !== owner || reservedIds.has(issue.number) || issue.assignees?.length) return false; + if ([...HELD].some(label => labels.has(label))) return false; + const runs = [...(issue.body || '').matchAll(//g)].map(match => match[1]); + if (runs.some(run => completedRuns.has(run))) return false; + return inspected[issue.number] !== fingerprint(issue); + }).map(issue => ({ ...issue, mode: issue.labels.some(label => label.name === 'execution:ready') ? 'validate-ready-contract' : 'prepare-contract-only' })); +} + +export function gate({ now, config, state, workers, externalPending = [] }) { + if (!config.enabled) return 'disabled'; + if (now >= config.expiresAt || state.rounds.length >= config.maxRounds) return 'budget-finished'; + if (externalPending.length) return 'external-reconciliation'; + if (state.rounds.some(round => round.state === 'uncertain')) return 'needs-reconciliation'; + if (workers.some(worker => BUSY.has(worker.status))) return 'waiting-for-team'; + return 'ready'; +} + +export function decisionsOf(value, round) { + if (value?.cycle_id !== round.id || !Array.isArray(value.decisions)) throw new Error('A matching cycle manifest is required'); + const allowed = new Set(round.issues.map(issue => issue.number)); + const seen = new Set(); + return value.decisions.map(decision => { + if (!allowed.has(decision.issue) || seen.has(decision.issue) || !['assigned', 'blocked', 'already-claimed', 'needs-contract', 'completed'].includes(decision.action) || typeof decision.reason !== 'string' || !decision.reason.trim()) throw new Error('Invalid decision'); + seen.add(decision.issue); + return { issue: decision.issue, action: decision.action, reason: decision.reason.slice(0, 2000) }; + }); +} diff --git a/bin/office-autopilot.js b/bin/office-autopilot.js new file mode 100644 index 000000000..439fa6797 --- /dev/null +++ b/bin/office-autopilot.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** An opt-in, finite supervisor. It wakes an idle coordinator; it never edits GitHub issues. */ +import { readFileSync, writeFileSync, renameSync, mkdirSync, lstatSync, existsSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { randomUUID } from 'node:crypto'; +import { WebSocket } from 'ws'; +import { candidates, decisionsOf, fingerprint, gate } from './autopilot/policy.mjs'; + +const exec = promisify(execFile); +const configFile = process.argv[2]; +if (!configFile) throw new Error('Usage: office-autopilot.js /absolute/path/to/local-config.json'); +const config = JSON.parse(readFileSync(configFile, 'utf8')); +if (!Number.isSafeInteger(config.maxRounds) || config.maxRounds < 1 || config.maxRounds > 20 || !Number.isFinite(config.expiresAt) || !Number.isSafeInteger(config.pollSeconds) || config.pollSeconds < 15 || config.pollSeconds > 600 || !Number.isSafeInteger(config.maxNewWorkers) || config.maxNewWorkers < 1 || config.maxNewWorkers > 4) throw new Error('Finite rounds, expiry, polling and worker limits are required'); +const project = realpathSync(config.projectDir); +mkdirSync(path.dirname(config.stateFile), { recursive: true }); +const read = file => JSON.parse(readFileSync(file, 'utf8')); +function write(file, value) { + const tmp = file + '.' + randomUUID() + '.tmp'; + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); + renameSync(tmp, file); +} +const state = existsSync(config.stateFile) ? read(config.stateFile) : { rounds: [], inspected: {} }; +// A crash after preparation may have delivered the request. Never blindly replay it. +for (const round of state.rounds) if (round.state === 'prepared') round.state = 'uncertain'; +write(config.stateFile, state); +let cookie = ''; +const base = config.baseUrl; +// HTTP cookies are scoped by the native host/port. Use that same origin for WS +// rather than mixing a public proxy Host with a loopback login cookie. +const origin = new URL(base).origin; +async function request(route, body) { + const response = await fetch(base + route, { method: body === undefined ? 'GET' : 'POST', headers: { Host: new URL(origin).host, Origin: origin, Cookie: cookie, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15_000) }); + if (!response.ok) { const error = new Error(`Office request refused (${response.status})`); error.status = response.status; throw error; } + return response; +} +async function login() { + const response = await request('/api/login', { password: readFileSync(config.passwordFile, 'utf8').trim() }); + cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; '); + if (!cookie) throw new Error('Office session unavailable'); +} +async function snapshot() { + return new Promise((resolve, reject) => { + const url = new URL('/ws', base); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.search = new URLSearchParams({ floor: config.floor, name: 'Office autopilot' }).toString(); + const ws = new WebSocket(url, { headers: { Host: new URL(origin).host, Origin: origin, Cookie: cookie } }); + const timer = setTimeout(() => { ws.terminate(); reject(new Error('Office snapshot timeout')); }, 10_000); + ws.on('error', () => { clearTimeout(timer); reject(new Error('Office snapshot unavailable')); }); + ws.on('message', raw => { + const message = JSON.parse(String(raw)); + if (message.t === 'welcome') { clearTimeout(timer); ws.close(); resolve(message); } + }); + }); +} +function status(kind, summary, extra = {}) { + write(path.join(project, '.agent-office/autopilot-status.json'), { state: kind, summary, at: Date.now(), rounds: state.rounds.length, maxRounds: config.maxRounds, expiresAt: config.expiresAt, ...extra }); +} +function manifestFile(worker, round) { + const root = realpathSync(path.resolve(project, worker.worktree?.path || '.')); + if (root !== project && !root.startsWith(project + path.sep)) throw new Error('Coordinator outside project'); + const output = path.join(root, 'outputs/office-autopilot', round.id + '.json'); + if (!existsSync(output)) return; + if (!lstatSync(output).isFile() || realpathSync(output) !== output || lstatSync(output).size > 100_000) throw new Error('Invalid cycle manifest file'); + return output; +} +async function tick() { + const current = JSON.parse(readFileSync(configFile, 'utf8')); + // The running process cannot extend its own campaign after a config edit. + const effective = { ...config, enabled: current.enabled === true, expiresAt: Math.min(config.expiresAt, current.expiresAt) }; + const budget = gate({ now: Date.now(), config: effective, state, workers: [] }); + if (budget !== 'ready' && !state.rounds.some(round => round.state === 'sent')) return status(budget, 'Automatic selection paused or its finite campaign has ended.'); + await login(); + const office = await snapshot(); + const workers = office.workers || []; + const coordinator = workers.find(worker => worker.name === config.coordinatorName); + const outstanding = state.rounds.find(round => round.state === 'sent'); + if (outstanding) { + if (!coordinator) return status('needs-reconciliation', 'The coordinator of a sent cycle is missing.'); + const file = manifestFile(coordinator, outstanding); + if (file && !['starting', 'working', 'needs_input'].includes(coordinator.status)) { + const decisions = decisionsOf(read(file), outstanding); + for (const decision of decisions) state.inspected[decision.issue] = outstanding.issues.find(issue => issue.number === decision.issue).fingerprint; + Object.assign(outstanding, { state: 'recorded', decisions, manifest: file, finishedAt: Date.now() }); + write(config.stateFile, state); + } else return status('waiting-for-coordinator', 'The current selection cycle is still running; it will not be replayed.'); + } + const sourceStatus = config.externalStatusFile ? read(config.externalStatusFile) : {}; + const sourceQueue = config.externalQueueFile ? read(config.externalQueueFile) : { tasks: [] }; + const waiting = gate({ now: Date.now(), config: effective, state, workers, externalPending: sourceStatus.pending_reconciliation || [] }); + if (waiting !== 'ready') return status(waiting, 'Waiting for existing work, QA or reconciliation before selecting another issue.', { activeWorkers: workers.filter(worker => ['starting', 'working', 'needs_input'].includes(worker.status)).map(worker => worker.name) }); + const { stdout } = await exec(config.githubCli || 'gh', ['issue', 'list', '--repo', config.repository, '--state', 'open', '--limit', '500', '--json', 'number,title,body,labels,assignees,updatedAt,author,url'], { cwd: project, timeout: 30_000, maxBuffer: 10_000_000 }); + const issues = JSON.parse(stdout); + // A live native ready-contract dispatcher owns all ready execution, including + // a new stage it has not claimed yet. This supervisor must not race it. + const nativeReady = sourceStatus.discover_ready ? issues.filter(issue => issue.labels.some(label => label.name === 'execution:ready')).map(issue => issue.number) : []; + const available = candidates(issues, { owner: config.repository.split('/')[0], reserved: [...(config.reservedIssues || []), ...nativeReady, ...(sourceQueue.tasks || []).map(task => task.issue)], completed: sourceStatus.completed_jobs || [], inspected: state.inspected }); + if (!available.length) return status('waiting-for-eligible-issue', 'No new unheld, unclaimed issue is eligible; completed stages will not be replayed.', { openIssues: issues.length, heldIssues: issues.filter(issue => issue.labels.some(label => label.name === 'execution:held')).length }); + const profiles = (await (await request('/api/worker-profiles?floor=' + encodeURIComponent(config.floor))).json()).profiles; + const profile = profiles.find(profile => profile.name === config.coordinatorName && profile.enabled); + if (!profile) return status('coordinator-held', 'The configured coordinator is unavailable; no substitute was selected.'); + const round = { id: 'cycle-' + randomUUID(), state: 'prepared', at: Date.now(), issues: available.slice(0, 12).map(issue => ({ number: issue.number, fingerprint: fingerprint(issue) })) }; + const output = 'outputs/office-autopilot/' + round.id + '.json'; + const prompt = `AUTOPILOT ${round.id}\nUser authorized autonomous selection of board issues in this experimental copy. Continue as the named coordinator, preserving exact team routes and source holds. First collect completed workers' outputs and independent QA. Select from the issue candidates below after reading each current Issue and all comments; board titles are not task contracts. Check current writers and dependencies.\n\nHard limits: at most ${config.maxNewWorkers} new worker launches this cycle, at most 15 minutes per worker, no extra agents beyond that, no CLI/model/effort fallback, no unbounded polling. Reuse an idle existing worker by office-workers tell rather than duplicate its persona. The current available profiles are ${JSON.stringify(profiles.filter(profile => profile.enabled).map(profile => ({ name: profile.name, provider: profile.provider, model: profile.model, effort: profile.effort })))}.\n\nIssues marked prepare-contract-only are for read-only assessment and a local proposed task contract; do not execute their underlying task. Only validate-ready-contract items may execute, after checking a complete first-party typed contract, fresh unique stage, correct writer/reviewer, dependencies and scoped write permission. Never remove holds, replay completed stages, claim another writer's task, or count a draft as acceptance. No original Orch mutation, GitHub issue/PR/label/state writes, production changes, publishing, credential operations, purchases, external messages or new recurring jobs. Local worktrees and outputs only. Independent QA stays separate. Preserve all more specific source STOPs.\n\n${config.instructions || ''}\n\nCandidates: ${JSON.stringify(available.slice(0, 12).map(issue => ({ number: issue.number, title: issue.title, mode: issue.mode, url: issue.url })))}\n\nBefore ending, write ONLY this selection manifest in your worktree at ${output}: {"cycle_id":"${round.id}","decisions":[{"issue":NUMBER,"action":"assigned|blocked|already-claimed|needs-contract|completed","reason":"concrete evidence and artifact/worker id"}]}. Use needs-contract for preparation that has no execution grant. Use completed only for actual accepted scope; selection is not completion. Cover each candidate you assessed, report artifact paths, then stop. This finite supervisor will select the next round later.`; + state.rounds.push(round); write(config.stateFile, state); + try { + const response = await request('/api/worker-profiles?floor=' + encodeURIComponent(config.floor), { id: profile.id, prompt, reuseIdle: true }); + const result = await response.json(); + Object.assign(round, { state: 'sent', workerId: result.workerId, sentAt: Date.now() }); + write(config.stateFile, state); + status('selecting', 'The coordinator is selecting the next suitable issue and checking its task contract.', { cycle: round.id }); + } catch (error) { + round.state = error.status === 409 ? 'refused' : 'uncertain'; write(config.stateFile, state); + status(round.state === 'refused' ? 'waiting-for-team' : 'needs-reconciliation', round.state === 'refused' ? 'The coordinator became busy or its profile was held; no duplicate task was sent.' : 'A dispatch response was uncertain; automatic replay is blocked.'); + } +} +let stopped = false; +process.on('SIGTERM', () => { stopped = true; }); +process.on('SIGINT', () => { stopped = true; }); +while (!stopped) { + try { await tick(); } catch { cookie = ''; status('unavailable', 'A required source or service could not be verified; no new work was selected.'); } + if (process.argv.includes('--once')) break; + await new Promise(resolve => { + const finish = () => { clearTimeout(timer); process.off('SIGTERM', finish); process.off('SIGINT', finish); resolve(); }; + const timer = setTimeout(finish, config.pollSeconds * 1000); + process.once('SIGTERM', finish); process.once('SIGINT', finish); + }); +} diff --git a/docs/agents.md b/docs/agents.md index 02016b48b..e38f30436 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,5 +1,55 @@ # Choosing an agent +Named teams can be configured per floor in `.agent-office/worker-profiles.json`. +The **Team** button is always visible on a floor with profiles and counts the entire +roster, including held members. In the office map, members without a running worker +appear at free desks or in the team area, labelled **Available** or **Held by source**. +Press **E** at a member to view their profile. These avatars do not start terminals +or count as running workers; source holds stay in effect when assigning a task. +Open **Menu → Named workers** to see each name, role, provider, model, effort and +source hold. **Give a task** starts that exact profile in its own worktree and +uses its name at the desk. It never substitutes a missing model or effort. +Profiles with `enabled: false`, nonempty `holds`, or an invalid route cannot start. +The file contains an array of `{id,name,description,provider,model,effort,enabled,holds}`. +Keep credentials out of it. This configuration does not change GitHub issues or +release source execution holds. + +## Optional finite autopilot + +`node bin/office-autopilot.js /absolute/path/to/config.json` runs an opt-in supervisor. +It waits for current workers and QA, then asks a configured named coordinator to +select suitable issues. It never changes GitHub tasks itself. Source holds, +assignees, reserved issues, completed native stage markers and prior inspections +exclude work. An unheld issue without `execution:ready` is **contract preparation +only**; ready work still requires validating its current contract and dependencies. +The named-team window shows the supervisor's actual status. + +The local JSON configuration requires `baseUrl`, `origin`, `floor`, `repository`, +`projectDir`, `passwordFile` (the office password file), `stateFile`, +`coordinatorName`, `enabled`, `expiresAt` (Unix milliseconds), `maxRounds` (1–20), +`maxNewWorkers` (1–4 per round), and `pollSeconds` (15–600). Optional +`externalStatusFile`/`externalQueueFile` read a native coordinator's +`completed_jobs`, `pending_reconciliation`, and `tasks[].issue`; `reservedIssues` +protects other known writers. `instructions` adds local project restrictions. +Keep this configuration and its credentials outside Git. Run only one instance, +using a supervisor and an OS lock such as `flock`; do not extend a running +campaign's expiry or reuse its state for another campaign. + +Each cycle is written to the state journal before sending. A lost response or +restart during dispatch blocks replay for reconciliation. The coordinator writes +a cycle-specific decision manifest before another selection. Rounds, wall-clock +expiry and per-worker 15-minute instructions bound the campaign; use the launcher’s +own resource/lease guards as well. This supervisor does not install or bypass those +guards. `--once` runs one observation for diagnostics. The authenticated profile +POST accepts `reuseIdle: true` to reuse a matching idle/offline coordinator, refusing +busy workers, held profiles and changed model routes. + +Set `AGENT_OFFICE_RESTORE_WORKERS=interrupted` to leave completed or failed +terminals asleep after an office restart or a page/floor visit, while retaining live terminals and +resuming genuinely interrupted turns. The default still wakes every saved worker. +This prevents a server update from opening an idle CLI for every old desk and +reserving capacity that the next queued task needs. + Back to the [README](../README.md). Every new worker starts on the office's **Default worker**: a provider, model and effort an admin picks in ⚙️ Settings → **🤖 Workers** (the `--agent` with its own default model until someone does). The hire, ask, queue, issue and meeting windows show it on one line; click **✏️ Edit** there to pick **Claude Code**, **OpenCode**, **Codex**, **Grok**, **Muse**, **DeepSeek Harness**, **Pi** or **Cursor**, a model and an effort for just that worker or task, and **↺ Use the default** to go back. Board agents and tasks the Queue agent adds, which nobody picks for, start on the default too. Existing workers keep their provider when prompted or resumed, and queued tasks keep their choice when retried or restored after a restart. diff --git a/docs/configuration.md b/docs/configuration.md index 611e23f0d..9b2618249 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,6 +10,12 @@ Already have a checkout? Pick its repository anyway: a checkout of it that's alr ## Command line +An issue board normally loads 300 open issues and the 40 most recently closed. +For a larger project, set `.agent-office/issue-board-limits.json` in that floor's +checkout, for example `{"open":1000,"closed":40}`. The supported ranges are +1–5000 open and 1–1000 closed issues; the other field keeps its default when omitted. +This only changes how many existing GitHub records are displayed. + ``` agent-office [dir] [options] diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..068d44ab4 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -24,6 +24,7 @@ import { openTeam } from '../../ui/team'; import { openUpgrade } from '../../ui/upgrade'; import { openWhiteboard } from '../whiteboard/ui'; import { describeSky } from '../../world/sky'; +import { openWorkerProfiles } from '../../ui/worker-profiles'; export type HudParts = Pick; @@ -52,6 +53,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { { id: 'issues', icon: '📌', label: 'Issues', section: 'Open', count: () => store.issues.items.filter((i) => i.state === 'OPEN').length, run: () => openBoard('issues', net, actions.boardActions()) }, { id: 'pulls', icon: '🔀', label: 'Pull requests', section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, { id: 'queue', icon: '📋', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, + { id: 'worker-profiles', icon: '👔', label: 'Named workers', section: 'Open', title: () => 'Named profiles, their exact models and source holds', run: openWorkerProfiles }, { id: 'services', icon: '🌐', label: 'Services', section: 'Open', count: () => store.services.items.length, title: () => 'Web servers the workers are running', run: () => openServices() }, { id: 'whiteboard', icon: '📝', label: 'Whiteboard', section: 'Open', title: () => 'Draw together, live', run: () => openWhiteboard(net) }, // Up on the top bar while a meeting is on: what's being worked through in the meeting room. diff --git a/src/client/features/roster/index.ts b/src/client/features/roster/index.ts new file mode 100644 index 000000000..99730a8eb --- /dev/null +++ b/src/client/features/roster/index.ts @@ -0,0 +1,106 @@ +import * as THREE from 'three'; +import type { WorkerProfile } from '../../../shared/worker-profiles'; +import { waitingProfiles } from '../../../shared/roster-presence'; +import { deskBuilt } from '../../../shared/layout'; +import type { Ctx } from '../../core/context'; +import { aside, hintTitle, key, onE } from '../../core/hint'; +import { store } from '../../state'; +import { h } from '../../ui/dom'; +import { openWorkerProfiles } from '../../ui/worker-profiles'; +import { Worker } from '../../world/character'; +import type { Interactable } from '../../world/types'; +import './ui.css'; + +declare module '../../world/types' { + interface InteractKinds { roster: true; } +} + +/** Shows the whole configured team without starting terminals, charging tokens or releasing holds. */ +export function installRoster(ctx: Ctx) { + const group = new THREE.Group(); + group.name = 'named-team-presence'; + ctx.scene.add(group); + let profiles: WorkerProfile[] = []; + let generation = 0; + let drawn = ''; + const models: Worker[] = []; + let items: Interactable[] = []; + const people = new WeakMap(); + const badge = h('button.btn.dock-btn', { id: 'roster-team', type: 'button', hidden: true, onclick: () => openWorkerProfiles() }) as HTMLButtonElement; + document.getElementById('hud')!.append(badge); + + function clear() { + for (const model of models) { model.root.removeFromParent(); model.dispose(); } + models.length = 0; + items = []; + } + + function draw() { + const signature = JSON.stringify([store.floor, ctx.plan().id, store.floorPlan.wing, store.theme.active, profiles, [...store.workers.values()].map(worker => [worker.name, worker.deskId])]); + if (signature === drawn) return; + drawn = signature; + clear(); + badge.hidden = !profiles.length; + badge.textContent = `👔 Team ${profiles.length}`; + badge.title = `${profiles.filter(profile => profile.enabled).length} available · ${profiles.filter(profile => !profile.enabled).length} held · assigned workers shown separately under Workers`; + group.userData.rosterCount = profiles.length; + if (!ctx.inOffice()) return; + const seats = [...ctx.world().desks.values()].filter(desk => !desk.def.station && !desk.def.room && !desk.def.beanbag && deskBuilt(desk.def, store.floorPlan.wing) && !store.workerAtDesk(desk.def.id)); + waitingProfiles(profiles, store.workers.values()).forEach((profile, index) => { + const model = new Worker(profile.name, profile.enabled ? '#06a877' : '#b88748'); + model.setStatus('idle', false); + model.setCostume(store.theme.active); + model.setTask({ name: profile.enabled ? 'Available · give a task' : 'Held by source', summary: profile.holds.join('; ') || profile.description }); + const seat = seats[index]; + if (seat) { + seat.seatAnchor.updateWorldMatrix(true, false); + seat.seatAnchor.getWorldPosition(model.root.position); + seat.seatAnchor.getWorldQuaternion(model.root.quaternion); + } else { + // The open aisle east of the desk pods: a standing team area once the desks fill up. + const slot = index - seats.length; + model.root.position.set(4.4 + (slot % 2) * 2, 0, -7 + Math.floor(slot / 2) * 2.1); + } + const it: Interactable = { kind: 'roster', x: model.root.position.x, z: model.root.position.z, radius: 0.9 }; + model.root.userData.interact = it; + people.set(it, profile); + items.push(it); + group.add(model.root); + models.push(model); + }); + group.userData.waitingCount = models.length; + } + + async function load() { + const current = ++generation; + profiles = []; + draw(); + if (!store.floor) return; + try { + const response = await fetch(`/api/worker-profiles?floor=${encodeURIComponent(store.floor)}`, { cache: 'no-store' }); + if (!response.ok) return; + const result = await response.json(); + if (current !== generation) return; + profiles = result.profiles; + draw(); + } catch { /* The team's window can retry; an unavailable roster never spawns a default worker. */ } + } + + store.on('floor', () => void load()); + store.on('workers', draw); + store.on('theme', draw); + store.on('floorPlan', draw); + ctx.ticks.add('others', ({ dt, t }) => { + group.visible = ctx.inOffice(); + for (const model of models) model.update(dt, t); + }); + ctx.usables.add({ usable: () => items, pickable: () => group }); + ctx.interactions.define('roster', { + reach: 3.5, + hint: it => { + const profile = people.get(it); + return { k: profile?.id ?? '', parts: [hintTitle(profile?.name ?? 'Team member'), aside(profile?.enabled ? 'Available · no task running' : 'Held by source'), key('E', 'View profile')] }; + }, + use: onE(it => openWorkerProfiles(people.get(it)?.id)), + }); +} diff --git a/src/client/features/roster/ui.css b/src/client/features/roster/ui.css new file mode 100644 index 000000000..1b0d9244e --- /dev/null +++ b/src/client/features/roster/ui.css @@ -0,0 +1,2 @@ +#roster-team { position: absolute; top: 66px; left: 12px; } +#roster-team[hidden] { display: none; } diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..a6254bdde 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -64,6 +64,7 @@ import { installWalking } from './features/walking'; import { installWhiteboard } from './features/whiteboard'; import { installWorkerActions } from './features/workers/actions'; import { installWorkerViews } from './features/workers/views'; +import { installRoster } from './features/roster'; // The loading screen stays up until there's an office to see (see boot and whoami at the end). const loading = loadingScreen(onModelsProgress); @@ -142,6 +143,7 @@ parts.maps = installMaps(ctx, core, parts); parts.peers = installPeers(ctx, core, parts); parts.walking = installWalking(ctx, core, parts); parts.views = installWorkerViews(ctx, core, parts); +installRoster(ctx); parts.actions = installWorkerActions(ctx, core, parts); parts.waiting = installWaiting(ctx, core, parts); parts.needsYou = installNeedsYou(ctx, parts); diff --git a/src/client/ui/worker-profiles.css b/src/client/ui/worker-profiles.css new file mode 100644 index 000000000..5de8c9f86 --- /dev/null +++ b/src/client/ui/worker-profiles.css @@ -0,0 +1,10 @@ +.worker-profiles { width: min(1100px, 94vw); } +.worker-profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(245px, 1fr)); gap: 12px; } +.worker-profile { border: 2px solid var(--line, #303248); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; } +.worker-profile h3, .worker-profile p { margin: 0; } +.worker-profile p { font-size: 12px; line-height: 1.5; } +.worker-profile-route { font-size: 11px; overflow-wrap: anywhere; } +.worker-profile-hold { color: #9b3426; font-size: 12px; } +.worker-profile .btn { margin-top: auto; } +.worker-profile-task { width: min(620px, 94vw); } +.worker-profile-prompt { display: block; width: 100%; margin: 14px 0; box-sizing: border-box; } diff --git a/src/client/ui/worker-profiles.ts b/src/client/ui/worker-profiles.ts new file mode 100644 index 000000000..7c4ec6b1b --- /dev/null +++ b/src/client/ui/worker-profiles.ts @@ -0,0 +1,57 @@ +import type { WorkerProfile } from '../../shared/worker-profiles'; +import { store } from '../state'; +import { h, openModal } from './dom'; +import './worker-profiles.css'; + +/** Named profiles are local configuration, separate from online human accounts. */ +export function openWorkerProfiles(focusId?: string) { + const content = h('div.modal.worker-profiles'); + const status = h('p.dim', {}, 'Loading worker profiles…'); + const list = h('div.worker-profile-grid'); + content.append(h('header', {}, h('h2', {}, 'Named workers')), status, list); + const modal = openModal(content, { doing: 'looking at the worker team' }); + const floor = store.floor; + if (!floor) { status.textContent = 'Choose a project floor first.'; return; } + const endpoint = `/api/worker-profiles?floor=${encodeURIComponent(floor)}`; + void fetch(endpoint).then(async response => { + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Could not load profiles'); + const profiles: WorkerProfile[] = result.profiles; + status.textContent = profiles.length ? `${profiles.length} profiles · ${profiles.filter(profile => profile.enabled).length} available · ${profiles.filter(profile => !profile.enabled).length} held. Model and effort come from each profile.` : 'No named profiles configured for this floor.'; + if (result.autopilot) status.after(h('p', {}, `Autopilot: ${result.autopilot.state} · ${result.autopilot.summary}`)); + for (const profile of profiles) { + const card = h('article.worker-profile'); + const current = [...store.workers.values()].find(worker => worker.name === profile.name); + const route = [profile.provider || 'Unspecified CLI', profile.model || 'Unspecified model', profile.effort || 'Unspecified effort'].join(' · '); + card.append(h('h3', {}, profile.name), h('p', {}, profile.description || 'No role specified'), h('code.worker-profile-route', {}, route)); + if (profile.holds.length) card.append(h('p.worker-profile-hold', {}, profile.holds.join('; '))); + if (current) card.append(h('p', {}, `At ${current.deskId} · ${current.status}`)); + const hire = h('button.btn', { disabled: !profile.enabled || !!current, onclick: () => requestTask(profile) }, current ? 'Already at a desk' : profile.enabled ? 'Give a task' : 'Held by source'); + card.append(hire); + list.append(card); + if (profile.id === focusId) { + card.tabIndex = -1; + requestAnimationFrame(() => { card.scrollIntoView({ block: 'center' }); card.focus(); }); + } + } + }).catch(error => { status.textContent = error.message; }); + + function requestTask(profile: WorkerProfile) { + const prompt = h('textarea.worker-profile-prompt', { rows: 6, placeholder: `Task for ${profile.name}`, 'aria-label': `Task for ${profile.name}` }) as HTMLTextAreaElement; + const error = h('p.worker-profile-hold'); + const task = h('div.modal.worker-profile-task', {}, h('header', {}, h('h2', {}, profile.name)), h('p', {}, `${profile.model} · ${profile.effort}`), prompt, error); + const taskModal = openModal(task); + const submit = h('button.btn.primary', { onclick: async () => { + if (!prompt.value.trim()) { prompt.focus(); return; } + (submit as HTMLButtonElement).disabled = true; + try { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: profile.id, prompt: prompt.value }) }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Could not start worker'); + taskModal.close(); modal.close(); + } catch (failure) { error.textContent = (failure as Error).message; (submit as HTMLButtonElement).disabled = false; } + } }, 'Start at a desk'); + task.append(h('footer', {}, submit)); + prompt.focus(); + } +} diff --git a/src/server/github.ts b/src/server/github.ts index bb089336b..ffdc8c8d0 100644 --- a/src/server/github.ts +++ b/src/server/github.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process'; import type { GhCheck, GhCloseReason, GhComment, GhIssue, GhIssueDetail, GhLabel, GhMergeMethod, GhPull, GhPullDetail, GhRepoInfo, GhReviewComment, GhState } from '../shared/protocol.js'; import type { GhAs } from './signins.js'; +import { issueBoardLimits } from './issue-board-limits.js'; const REFRESH_MS = 90_000; /** How long the repo's list of labels is kept before the label picker asks GitHub again. */ @@ -456,9 +457,10 @@ export class GitHub { try { // Open and closed separately, so old open issues are never crowded out by recent closed ones. const fields = 'number,title,state,url,author,labels,assignees,createdAt,updatedAt,body,comments'; + const limit = issueBoardLimits(this.dir); const [open, closed] = await Promise.all([ - this.runGh(['issue', 'list', '--state', 'open', '--limit', '300', '--json', fields], this.dir), - this.runGh(['issue', 'list', '--state', 'closed', '--limit', '40', '--json', fields], this.dir), + this.runGh(['issue', 'list', '--state', 'open', '--limit', String(limit.open), '--json', fields], this.dir), + this.runGh(['issue', 'list', '--state', 'closed', '--limit', String(limit.closed), '--json', fields], this.dir), ]); const fetched: GhIssue[] = [...JSON.parse(open), ...JSON.parse(closed)].map((i: any) => ({ number: i.number, diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..8480b9de0 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -9,6 +9,7 @@ import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; +import { workerProfileRoutes } from './worker-profiles.js'; export const routes: readonly Route[] = [ // Anyone. @@ -28,6 +29,7 @@ export const routes: readonly Route[] = [ // Signed in. authRoutes.whoami, agentRoutes.models, + workerProfileRoutes.profiles, fileRoutes.image, fileRoutes.whiteboardFile, fileRoutes.termDrop, diff --git a/src/server/http/routes/worker-profiles.ts b/src/server/http/routes/worker-profiles.ts new file mode 100644 index 000000000..4408a63b3 --- /dev/null +++ b/src/server/http/routes/worker-profiles.ts @@ -0,0 +1,45 @@ +import { readBody, sameOrigin, send } from '../util.js'; +import type { Route } from '../router.js'; +import { floorParam } from './files.js'; +import { loadAutopilotStatus, loadWorkerProfiles, profilePrompt } from '../../worker-profiles.js'; +import { DESK_BY_ID, deskBuilt } from '../../../shared/layout.js'; +import path from 'node:path'; + +export const workerProfileRoutes = { + profiles: { + path: '/api/worker-profiles', auth: 'session', + async handle(ctx, { req, res, url, session }) { + const floor = floorParam(ctx, url); + if (!floor) return send(res, 404, { error: 'No such floor' }); + try { + const profiles = loadWorkerProfiles(path.join(floor.dir, '.agent-office')); + if (req.method === 'GET') return send(res, 200, { profiles, autopilot: loadAutopilotStatus(path.join(floor.dir, '.agent-office')) }); + if (req.method !== 'POST') return send(res, 405, { error: 'Method not allowed' }); + if (!sameOrigin(req, ctx.cfg)) return send(res, 403, { error: 'Forbidden' }); + const body = JSON.parse(await readBody(req, 24_000)); + const profile = profiles.find(profile => profile.id === body.id); + if (!profile?.enabled || !profile.provider || !profile.model) return send(res, 409, { error: 'This profile is held; its route was not substituted' }); + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; + if (!prompt || prompt.length > 20_000) return send(res, 400, { error: 'Enter a task for this worker' }); + const current = floor.workers.list().find(worker => worker.name === profile.name); + if (current) { + if (body.reuseIdle !== true) return send(res, 409, { error: 'This worker already has a desk' }); + if (!['done', 'idle', 'offline', 'exited'].includes(current.status)) return send(res, 409, { error: 'This worker is busy or needs input' }); + if (current.provider !== profile.provider || current.model !== profile.model || current.effort !== profile.effort) return send(res, 409, { error: 'The existing worker route differs from its profile' }); + const text = profilePrompt(profile, prompt); + let error = floor.workers.prompt(current.id, text, session.account?.name || 'Office'); + if (error === 'Worker is not running') error = floor.workers.resume(current.id, text); + return error ? send(res, 409, { error }) : send(res, 200, { workerId: current.id, name: current.name }); + } + await floor.workers.fetchBase(); + if (floor.workers.list().some(worker => worker.name === profile.name)) return send(res, 409, { error: 'This worker already has a desk' }); + const desk = [...DESK_BY_ID.values()].find(desk => !desk.station && !desk.room && deskBuilt(desk, floor.plan.wing) && !floor.workers.deskOccupied(desk.id)); + if (!desk) return send(res, 409, { error: 'No free desk; expand the office or send a worker home' }); + const result = floor.workers.spawn(desk.id, session.account?.name || 'Office', profilePrompt(profile, prompt), true, + 'agent', profile.provider, profile.model, profile.effort, undefined, session.account?.id, [], undefined, profile.name); + if (typeof result === 'string') return send(res, 409, { error: result }); + return send(res, 201, { workerId: result.id, name: result.name }); + } catch (error) { return send(res, 400, { error: (error as Error).message }); } + }, + }, +} satisfies Record; diff --git a/src/server/issue-board-limits.ts b/src/server/issue-board-limits.ts new file mode 100644 index 000000000..cf7630675 --- /dev/null +++ b/src/server/issue-board-limits.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +export function issueBoardLimits(dir: string): { open: number; closed: number } { + let raw: Record; + try { raw = JSON.parse(readFileSync(path.join(dir, '.agent-office', 'issue-board-limits.json'), 'utf8')); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { open: 300, closed: 40 }; throw error; } + const count = (key: string, fallback: number, maximum: number) => { + const value = raw[key] ?? fallback; + if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum) throw new Error(`Invalid ${key} issue-board limit (1–${maximum})`); + return value as number; + }; + return { open: count('open', 300, 5000), closed: count('closed', 40, 1000) }; +} diff --git a/src/server/worker-profiles.ts b/src/server/worker-profiles.ts new file mode 100644 index 000000000..c6a489a20 --- /dev/null +++ b/src/server/worker-profiles.ts @@ -0,0 +1,61 @@ +import { lstatSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { isAgentEffort, isAgentProvider } from '../shared/protocol.js'; +import type { WorkerProfile } from '../shared/worker-profiles.js'; +import { validateWorkerEffort, validateWorkerModel } from './agents.js'; + +/** Optional local supervisor status. Only two bounded public fields are exposed. */ +export function loadAutopilotStatus(dataDir: string): { state: string; summary: string } | undefined { + try { + const file = path.join(dataDir, 'autopilot-status.json'); + const stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16_000) return; + const value = JSON.parse(readFileSync(file, 'utf8')); + if (typeof value?.state !== 'string' || typeof value?.summary !== 'string') return; + return { state: value.state.slice(0, 80), summary: value.summary.slice(0, 500) }; + } catch { return; } +} + +export function parseWorkerProfiles(value: unknown): WorkerProfile[] { + if (!Array.isArray(value) || value.length > 100) throw new Error('Profiles must be a list of at most 100 entries'); + const ids = new Set(); + const names = new Set(); + return value.map(raw => { + if (!raw || typeof raw !== 'object') throw new Error('Invalid worker profile'); + const row = raw as Record; + const text = (key: string, maximum: number) => typeof row[key] === 'string' && (row[key] as string).length <= maximum && !/[\x00-\x1f\x7f]/.test(row[key] as string) ? (row[key] as string).trim() : ''; + const id = text('id', 100), name = text('name', 80); + if (!id || !name || ids.has(id) || names.has(name)) throw new Error('Worker profile ids and names must be unique and nonempty'); + ids.add(id); names.add(name); + const provider = isAgentProvider(row.provider) && row.provider !== 'custom' ? row.provider : undefined; + const model = text('model', 256) || undefined; + const effort = isAgentEffort(row.effort) ? row.effort : undefined; + const holds = Array.isArray(row.holds) ? row.holds.filter((x): x is string => typeof x === 'string').map(x => x.slice(0, 200)).slice(0, 20) : []; + if (!provider) holds.push('Provider is not configured'); + if (!model) holds.push('Model is unspecified'); + if (provider && validateWorkerModel('agent', provider, model)) holds.push('Model is invalid for this provider'); + if (provider && validateWorkerEffort('agent', provider, effort)) holds.push('Effort is invalid for this provider'); + if (provider !== 'cursor' && !effort) holds.push('Effort is unspecified; no fallback is applied'); + if (row.enabled !== true && holds.length === 0) holds.push('Profile is held by its source'); + return { id, name, description: text('description', 4000), provider, model, effort, + enabled: row.enabled === true && holds.length === 0, holds, source: text('source', 500) || undefined }; + }); +} + +export function loadWorkerProfiles(dataDir: string): WorkerProfile[] { + const file = path.join(dataDir, 'worker-profiles.json'); + try { + const stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 512_000) throw new Error('Invalid worker profiles file'); + return parseWorkerProfiles(JSON.parse(readFileSync(file, 'utf8'))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +export function profilePrompt(profile: WorkerProfile, prompt: string): string { + return `Named worker: ${profile.name}.\nResponsibilities: ${profile.description}\n` + + 'Keep the requested model and reasoning effort. Preserve source holds, dependencies and independent QA. ' + + 'Do not change other workers or production services beyond this request. The persona is not a shared-memory author identity.\n\n' + prompt.trim(); +} diff --git a/src/server/workers/identity.ts b/src/server/workers/identity.ts new file mode 100644 index 000000000..3d4028a50 --- /dev/null +++ b/src/server/workers/identity.ts @@ -0,0 +1,6 @@ +/** Keep a person's display name while giving git an ASCII branch-safe slug. */ +export function workerSlug(name: string, id: string): string { + const stem = name.normalize('NFKD').toLowerCase().replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'worker'; + return `${stem}-${id.slice(0, 4)}`; +} diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index b19b2b3cd..0c0bf3e44 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -16,6 +16,7 @@ import { DSH_PROFILE_DEFAULT } from '../dsh.js'; import { DropStore } from '../drops.js'; import type { Capacity } from '../machine.js'; import { PROVIDERS, providerAdapter, titleNoise, type LaunchPlan, type ProviderFloor } from '../providers/index.js'; +import { noteBootTimeout } from './startup.js'; import { launchAcp } from './acp.js'; import { clockWork } from './clock.js'; import { childEnv } from './env.js'; @@ -28,8 +29,8 @@ import { flushScreens, fullScreens, newTerm, offlineBanner, screenText, type Hea import type { HookEnv, OpenedPr, RepoSource, RunAs, Worker, WorkerContext, WorkerEvents, WorkerHandle } from './types.js'; import { clamp, safeEq, truncate } from './util.js'; import { COLORS, NAMES, newWorker } from './worker.js'; +import { workerSlug } from './identity.js'; import { WorkerTrees, lostMessage } from './worktree.js'; - const SCREEN_INTERVAL_MS = 250; /** How often a steady typist's "last typed" time is refreshed for everyone. */ const TYPED_REFRESH_MS = 15_000; @@ -219,7 +220,7 @@ export class WorkerManager { * (see meetings.ts), in the meeting's own worktree, which everyone at the table shares. `repos` are * other floors' repositories a worker in its own worktree works in too (see makeWorkspace). */ - spawn(deskId: string, by: string, prompt?: string, worktree = false, kind: WorkerKind = 'agent', provider?: AgentProvider, model?: string, effort?: AgentEffort, meeting?: { id: string; worktree?: WorkerInfo['worktree'] }, owner?: string, repos: RepoSource[] = [], via?: 'herald'): WorkerInfo | string { + spawn(deskId: string, by: string, prompt?: string, worktree = false, kind: WorkerKind = 'agent', provider?: AgentProvider, model?: string, effort?: AgentEffort, meeting?: { id: string; worktree?: WorkerInfo['worktree'] }, owner?: string, repos: RepoSource[] = [], via?: 'herald', requestedName?: string): WorkerInfo | string { // Nobody picked (a board agent, say): the office's default worker, model and effort included. if (kind === 'agent' && provider === undefined) ({ provider, model, effort } = this.officeDefault); const selectedProvider = kind === 'agent' ? provider : undefined; @@ -249,12 +250,12 @@ export class WorkerManager { if (full) return full; const used = new Set([...this.workers.values()].map((w) => w.info.name.replace(/ 🐚$/, ''))); const agent = seat.station && STATION_AGENT[seat.station]; - const name = agent ? agent.name : (NAMES.find((n) => !used.has(n)) ?? `Worker ${this.workers.size + 1}`); + const name = agent ? agent.name : requestedName || (NAMES.find((n) => !used.has(n)) ?? `Worker ${this.workers.size + 1}`); const id = randomBytes(6).toString('hex'); let wt: WorkerInfo['worktree'] = meeting?.worktree; let others: WorkerRepo[] | undefined; if (worktree) { - const slug = `${name.toLowerCase()}-${id.slice(0, 4)}`; + const slug = workerSlug(name, id); const made = repos.length ? this.makeWorkspace(slug, repos) : this.trees.create(slug); if (typeof made === 'string') return made; if ('repos' in made) { @@ -357,9 +358,9 @@ export class WorkerManager { } /** Starts every worker that isn't running: nobody should be found asleep at their desk. */ - wakeAll() { + wakeAll(interruptedOnly = process.env.AGENT_OFFICE_RESTORE_WORKERS === 'interrupted') { // A DeepSeek Harness worker has no PTY but is still running: only the ones that are gone wake up. - for (const w of this.workers.values()) if (!w.pty && !w.dsh) this.resume(w.info.id); + for (const w of this.workers.values()) if (!w.pty && !w.dsh && (!interruptedOnly || w.interrupted)) this.resume(w.info.id); } /** @@ -832,11 +833,8 @@ export class WorkerManager { // blocked on a human: folder trust dialog, login, first-run onboarding. Flag it so it jumps. setTimeout(() => { if (info.status !== 'starting' || w.pty !== proc) return; - if (adapter?.bootHint) { - w.bootBlocked = true; - info.activity = adapter.bootHint; - this.setStatus(w, 'needs_input'); - } else this.setStatus(w, 'idle'); + if (adapter?.bootHint) noteBootTimeout(this.handleOf(w), adapter.bootHint, w.snapshot?.() ?? ''); + else this.setStatus(w, 'idle'); }, 12000); } diff --git a/src/server/workers/startup.ts b/src/server/workers/startup.ts new file mode 100644 index 000000000..14a330d15 --- /dev/null +++ b/src/server/workers/startup.ts @@ -0,0 +1,9 @@ +import type { WorkerHandle } from './types.js'; + +/** A launcher waiting for resources is not an agent asking the user for permission. */ +export function noteBootTimeout(worker: Pick, hint: string, screen: string) { + const waiting = /Agent Office capacity guard: Waiting for capacity; no model started\./.test(screen.slice(-12_000)); + worker.bootBlocked = !waiting; + worker.info.activity = waiting ? 'Waiting for available RAM or a company slot; task preserved' : hint; + worker.setStatus(waiting ? 'starting' : 'needs_input'); +} diff --git a/src/server/ws/handlers/workers.ts b/src/server/ws/handlers/workers.ts index 3e468890c..a2a186d10 100644 --- a/src/server/ws/handlers/workers.ts +++ b/src/server/ws/handlers/workers.ts @@ -49,7 +49,7 @@ export const workerHandlers = { }, 'worker.resume'(ctx, c, msg) { const w = workerOf(ctx, msg.workerId); - ctx.warn(c, w ? w.floor.workers.resume(w.wid) : 'No such worker'); + ctx.warn(c, w ? w.floor.workers.resume(w.wid, str(msg.prompt, 20000) || undefined) : 'No such worker'); }, 'worker.kill'(ctx, c, msg) { const who = c.peer.name; diff --git a/src/shared/protocol/workers.ts b/src/shared/protocol/workers.ts index 1f83346fb..59b57d62b 100644 --- a/src/shared/protocol/workers.ts +++ b/src/shared/protocol/workers.ts @@ -178,7 +178,7 @@ export type WorkerClientMsg = /** With `issue`, the worker is there for that GitHub issue: it moves to In progress at once, is assigned on GitHub (which keeps it there) and taken off the queue. */ /** With `repos` (other floors' ids), the worker works in their repositories too, each in a worktree of its own (see WorkerInfo.repos). */ | { t: 'worker.spawn'; deskId: string; prompt?: string; worktree?: boolean; kind?: WorkerKind; provider?: AgentProvider; model?: string; effort?: AgentEffort; issue?: number; repos?: string[]; via?: 'herald' } - | { t: 'worker.resume'; workerId: string } + | { t: 'worker.resume'; workerId: string; prompt?: string } | { t: 'worker.kill'; workerId: string; cleanup?: WorktreeCleanup } /** Asks what the worker's worktree holds; answered with a `worker.worktree` message. */ | { t: 'worker.worktree'; workerId: string } diff --git a/src/shared/roster-presence.ts b/src/shared/roster-presence.ts new file mode 100644 index 000000000..f19fbdd03 --- /dev/null +++ b/src/shared/roster-presence.ts @@ -0,0 +1,7 @@ +import type { WorkerProfile } from './worker-profiles.js'; + +/** Team membership is separate from running processes. Active workers already have an avatar. */ +export function waitingProfiles(profiles: WorkerProfile[], workers: Iterable<{ name: string }>): WorkerProfile[] { + const active = new Set([...workers].map(worker => worker.name)); + return profiles.filter(profile => !active.has(profile.name)); +} diff --git a/src/shared/worker-profiles.ts b/src/shared/worker-profiles.ts new file mode 100644 index 000000000..a0dae415d --- /dev/null +++ b/src/shared/worker-profiles.ts @@ -0,0 +1,14 @@ +import type { AgentEffort, AgentProvider } from './protocol/agents.js'; + +/** Locally managed named workers; credentials never belong in this file. */ +export interface WorkerProfile { + id: string; + name: string; + description: string; + provider?: AgentProvider; + model?: string; + effort?: AgentEffort; + enabled: boolean; + holds: string[]; + source?: string; +} diff --git a/tests/issue-board-limits.test.ts b/tests/issue-board-limits.test.ts new file mode 100644 index 000000000..083493024 --- /dev/null +++ b/tests/issue-board-limits.test.ts @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { issueBoardLimits } from '../src/server/issue-board-limits.js'; + +test('issue boards keep the existing defaults unless a floor configures larger lists', () => { + const root = mkdtempSync(path.join(tmpdir(), 'office-issue-limits-')); + try { + assert.deepEqual(issueBoardLimits(root), { open: 300, closed: 40 }); + mkdirSync(path.join(root, '.agent-office')); + writeFileSync(path.join(root, '.agent-office/issue-board-limits.json'), JSON.stringify({ open: 1000 })); + assert.deepEqual(issueBoardLimits(root), { open: 1000, closed: 40 }); + writeFileSync(path.join(root, '.agent-office/issue-board-limits.json'), JSON.stringify({ open: 100000 })); + assert.throws(() => issueBoardLimits(root), /Invalid open/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/office-autopilot.test.ts b/tests/office-autopilot.test.ts new file mode 100644 index 000000000..444df9e55 --- /dev/null +++ b/tests/office-autopilot.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { candidates, decisionsOf, fingerprint, gate } from '../bin/autopilot/policy.mjs'; +import { loadAutopilotStatus } from '../src/server/worker-profiles.js'; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { Readable } from 'node:stream'; +import { workerProfileRoutes } from '../src/server/http/routes/worker-profiles.js'; +import { mkdirSync } from 'node:fs'; + +const issue = (number: number, changes = {}) => ({ number, title: 'A task', author: { login: 'owner' }, updatedAt: '2026-10-26', labels: [], body: '', assignees: [], ...changes }); +test('autopilot rejects holds, assigned work, foreign owners, prior inspections and completed native stages', () => { + const inspected = issue(5); + const list = [issue(1), issue(2, { labels: [{ name: 'execution:held' }] }), issue(3, { assignees: [{ login: 'writer' }] }), issue(4, { author: { login: 'other' } }), inspected, issue(6, { body: '' }), issue(7), issue(8, { labels: [{ name: 'status:in-progress' }] })]; + const result = candidates(list, { owner: 'owner', reserved: [7], completed: ['completed-stage-123'], inspected: { 5: fingerprint(inspected) } }); + assert.deepEqual(result.map((row: any) => row.number), [1]); + assert.equal(result[0].mode, 'prepare-contract-only'); +}); +test('historical source-ready is preparation only; explicit ready still requires contract validation', () => { + const rows = candidates([issue(1, { labels: [{ name: 'source-status:ready' }] }), issue(2, { labels: [{ name: 'execution:ready' }] })], { owner: 'owner' }); + assert.deepEqual(rows.map((row: any) => row.mode), ['prepare-contract-only', 'validate-ready-contract']); +}); +test('budgets, active writers, pending reconciliation and uncertain delivery prevent another cycle', () => { + const config = { enabled: true, expiresAt: 100, maxRounds: 2 }; + const base = { now: 50, config, state: { rounds: [] }, workers: [] }; + assert.equal(gate(base), 'ready'); + assert.equal(gate({ ...base, now: 100 }), 'budget-finished'); + assert.equal(gate({ ...base, state: { rounds: [{}, {}] } }), 'budget-finished'); + for (const status of ['starting', 'working', 'needs_input']) assert.equal(gate({ ...base, workers: [{ status }] }), 'waiting-for-team'); + assert.equal(gate({ ...base, externalPending: ['unknown'] }), 'external-reconciliation'); + assert.equal(gate({ ...base, state: { rounds: [{ state: 'uncertain' }] } }), 'needs-reconciliation'); + assert.equal(gate({ ...base, config: { ...config, enabled: false } }), 'disabled'); +}); +test('only decisions for the exact round and its issue set can consume an inspection', () => { + const round = { id: 'round-1', issues: [{ number: 1 }] }; + const valid = { cycle_id: 'round-1', decisions: [{ issue: 1, action: 'needs-contract', reason: 'Missing acceptance criteria' }] }; + assert.equal(decisionsOf(valid, round)[0].issue, 1); + assert.throws(() => decisionsOf({ ...valid, cycle_id: 'old' }, round)); + assert.throws(() => decisionsOf({ ...valid, decisions: [{ issue: 2, action: 'completed', reason: 'wrong issue' }] }, round)); + assert.throws(() => decisionsOf({ ...valid, decisions: [...valid.decisions, ...valid.decisions] }, round)); +}); +test('autopilot status cannot expose private fields or follow a symlink', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'office-autopilot-')); + try { + const file = path.join(root, 'autopilot-status.json'); + writeFileSync(file, JSON.stringify({ state: 'waiting', summary: 'Existing writer is active', secret: 'private-marker' })); + assert.deepEqual(loadAutopilotStatus(root), { state: 'waiting', summary: 'Existing writer is active' }); + rmSync(file); writeFileSync(path.join(root, 'private.json'), '{"state":"private","summary":"private"}'); + symlinkSync(path.join(root, 'private.json'), file); + assert.equal(loadAutopilotStatus(root), undefined); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('idle profile reuse resumes the exact worker and rejects busy, changed or held routes', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'office-reuse-')); + const profile = { id: 'coordinator', name: 'Coordinator', description: 'Coordinates tasks', provider: 'codex', model: 'gpt-6-luna', effort: 'max', enabled: true, holds: [] }; + const data = path.join(root, '.agent-office'); mkdirSync(data); + const file = path.join(data, 'worker-profiles.json'); writeFileSync(file, JSON.stringify([profile])); + let current: any = { id: 'existing', name: profile.name, provider: profile.provider, model: profile.model, effort: profile.effort, status: 'offline' }; + const calls: string[] = []; + const ctx: any = { cfg: {}, floors: new Map([['floor', { dir: root, workers: { + list: () => [current], + prompt: (id: string) => { calls.push('prompt:' + id); return 'Worker is not running'; }, + resume: (id: string) => { calls.push('resume:' + id); }, + } }]]) }; + const run = async (reuseIdle = true) => { + const req: any = Readable.from([Buffer.from(JSON.stringify({ id: profile.id, prompt: 'Choose an eligible task', reuseIdle }))]); + req.headers = { host: 'office.test', origin: 'https://office.test' }; req.method = 'POST'; + let status = 0; let body: any; + const res: any = { writeHead: (code: number) => { status = code; }, end: (text: string) => { body = JSON.parse(text); } }; + await workerProfileRoutes.profiles.handle(ctx, { req, res, url: new URL('https://office.test/api/worker-profiles?floor=floor'), session: {} } as any); + return { status, body }; + }; + try { + assert.equal((await run()).status, 200); + assert.deepEqual(calls, ['prompt:existing', 'resume:existing']); calls.length = 0; + current.status = 'working'; assert.equal((await run()).status, 409); assert.deepEqual(calls, []); + current.status = 'needs_input'; assert.equal((await run()).status, 409); assert.deepEqual(calls, []); + current.status = 'idle'; current.model = 'gpt-6.1-sol'; assert.equal((await run()).status, 409); assert.deepEqual(calls, []); + current.model = profile.model; assert.equal((await run(false)).status, 409); assert.deepEqual(calls, []); + writeFileSync(file, JSON.stringify([{ ...profile, enabled: false, holds: ['Source STOP'] }])); + assert.equal((await run()).status, 409); assert.deepEqual(calls, []); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/roster-presence.test.ts b/tests/roster-presence.test.ts new file mode 100644 index 000000000..ea49dabcb --- /dev/null +++ b/tests/roster-presence.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { waitingProfiles } from '../src/shared/roster-presence.js'; +import type { WorkerProfile } from '../src/shared/worker-profiles.js'; + +test('team presence includes held members and removes only people already represented by workers', () => { + const profiles: WorkerProfile[] = [ + { id: 'ready', name: 'Ready Person', description: 'Developer', enabled: true, holds: [] }, + { id: 'held', name: 'Held Person', description: 'Reviewer', enabled: false, holds: ['Source STOP'] }, + { id: 'busy', name: 'Working Person', description: 'Developer', enabled: true, holds: [] }, + ]; + const visible = waitingProfiles(profiles, [{ name: 'Working Person' }, { name: 'Pixel' }]); + assert.deepEqual(visible.map(profile => profile.id), ['ready', 'held']); + assert.equal(visible[1].enabled, false); + assert.deepEqual(visible[1].holds, ['Source STOP']); + assert.equal(waitingProfiles(profiles, []).length, 3); +}); diff --git a/tests/worker-profiles.test.ts b/tests/worker-profiles.test.ts new file mode 100644 index 000000000..3b40999d9 --- /dev/null +++ b/tests/worker-profiles.test.ts @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadWorkerProfiles, parseWorkerProfiles, profilePrompt } from '../src/server/worker-profiles.js'; +import { workerSlug } from '../src/server/workers/identity.js'; +import { execFileSync } from 'node:child_process'; + +const worker = { id: 'employee-1', name: 'Example Worker', description: 'Implementation role', provider: 'codex', model: 'gpt-6-luna', effort: 'max', enabled: true, holds: [] }; +test('full names and punctuation produce actual git-valid worktree branches', () => { + for (const name of ['Example Worker', 'Mehmet Yağız Maktav', 'Reviewer [QA] / ../', '🚀']) { + const branch = 'office/' + workerSlug(name, 'abcd1234'); + assert.doesNotThrow(() => execFileSync('git', ['check-ref-format', '--branch', branch], { stdio: 'pipe' })); + } + assert.equal(workerSlug('Pixel', 'abcd1234'), 'pixel-abcd'); +}); +test('a named profile keeps its exact model and effort', () => { + const profile = parseWorkerProfiles([worker])[0]; + assert.equal(profile.name, worker.name); assert.equal(profile.model, 'gpt-6-luna'); assert.equal(profile.effort, 'max'); assert.equal(profile.enabled, true); + assert.match(profilePrompt(profile, 'Prepare a report'), /Named worker: Example Worker/); +}); +test('source holds and missing routes never fall back or enable a profile', () => { + for (const change of [{ enabled: false }, { holds: ['free-only source hold'] }, { effort: null }, { model: null }, { provider: 'custom' }, { provider: 'opencode', model: 'invalid' }]) { + const profile = parseWorkerProfiles([{ ...worker, ...change }])[0]; + assert.equal(profile.enabled, false, JSON.stringify(change)); assert.ok(profile.holds.length); + } +}); +test('duplicate identities and malformed records are rejected', () => { + assert.throws(() => parseWorkerProfiles([worker, worker]), /unique/); + assert.throws(() => parseWorkerProfiles([{ ...worker, name: 'bad\nname' }]), /nonempty/); + assert.throws(() => parseWorkerProfiles({ profiles: [worker] }), /list/); +}); +test('profile loading is optional and refuses symlinks', () => { + const root = mkdtempSync(path.join(tmpdir(), 'office-profiles-')); + try { + assert.deepEqual(loadWorkerProfiles(root), []); + const data = path.join(root, 'source.json'); writeFileSync(data, JSON.stringify([worker])); + symlinkSync(data, path.join(root, 'worker-profiles.json')); + assert.throws(() => loadWorkerProfiles(root), /Invalid worker profiles/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/worker-startup.test.ts b/tests/worker-startup.test.ts new file mode 100644 index 000000000..47eca760c --- /dev/null +++ b/tests/worker-startup.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { noteBootTimeout } from '../src/server/workers/startup.js'; + +test('resource waiting stays starting and does not claim a human permission is required', () => { + const worker: any = { info: {}, bootBlocked: false, setStatus: (status: string) => { worker.info.status = status; } }; + noteBootTimeout(worker, 'Complete login', 'Agent Office capacity guard: Waiting for capacity; no model started. remaining 2.3 GiB'); + assert.equal(worker.info.status, 'starting'); + assert.equal(worker.bootBlocked, false); + assert.match(worker.info.activity, /Waiting for available RAM/); + noteBootTimeout(worker, 'Complete login', 'Login required'); + assert.equal(worker.info.status, 'needs_input'); + assert.equal(worker.bootBlocked, true); +}); diff --git a/tests/workers.test.ts b/tests/workers.test.ts index 59a637b6b..6dbc5664d 100644 --- a/tests/workers.test.ts +++ b/tests/workers.test.ts @@ -1408,6 +1408,28 @@ test('a restart that takes a mid-turn worker down resumes it with continue; a fi assert.equal(promptOf(of('finished')), undefined); }); +test('interrupted-only restoration leaves completed desks asleep and resumes interrupted work', async (t) => { + const f = carryOnFixture(t); + const before = manager(f, f.claude, []); + await hireInState(f, before, 'desk-1', 'unfinished', 'working'); + const finished = await hireInState(f, before, 'desk-2', 'finished', 'done'); + before.shutdown(true); + await new Promise(resolve => setTimeout(resolve, 200)); + const after = manager(f, f.claude, []); t.after(() => after.shutdown()); + const previous = process.env.AGENT_OFFICE_RESTORE_WORKERS; + process.env.AGENT_OFFICE_RESTORE_WORKERS = 'interrupted'; + try { + await after.start(); + after.wakeAll(); // Visiting/reconnecting to a floor must honor the same policy. + assert.equal(after.get(finished.id)?.status, 'offline'); + } + finally { if (previous === undefined) delete process.env.AGENT_OFFICE_RESTORE_WORKERS; else process.env.AGENT_OFFICE_RESTORE_WORKERS = previous; } + await waitFor(() => launches(f), list => list.length >= 3); + assert.equal(after.get(finished.id)?.status, 'offline'); + assert.equal(launches(f).length, 3); + assert.equal(promptOf(launches(f)[2]), CARRY_ON_PROMPT); +}); + test('a worker whose terminal outlives the office is picked back up mid-turn, not relaunched or told to continue', async (t) => { const f = carryOnFixture(t); const before = manager(f, f.claude, []);