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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i
- **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎤**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install.
- **You can't miss who needs you.** A worker that stops to ask you something lights a red beacon over its desk, puts a banner on your screen saying who and what for, and sounds an alarm. One that has finished jumps up and down and dings. Press **N** to go straight to whoever is waiting.
- **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its terminal with the keys a phone keyboard lacks, and the boards. The 3D office offers it on a phone or a slow computer.
- **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others.
- **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). Creating a worker worktree allows up to three minutes for large checkouts and Git LFS downloads; a timeout reports its cause. Failed creation removes only its own new, unchanged checkout; existing folders, personal files (including ignored files) and commits are preserved. One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others.
- **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`).
- **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard.

Expand Down
2 changes: 2 additions & 0 deletions docs/code-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,5 @@ One adapter file in `src/server/providers/`, one entry in `PROVIDERS` in `src/se
`tests/size.test.ts` holds every `.ts` and `.css` file under `src/` to 600 lines. It asks `git ls-files` for them (tracked, and new ones that aren't ignored) rather than walking the folder, so a worktree checked out inside the repo can't trip it. The files that were longer when it came in are listed in `CEILINGS`, each with the length it had then: they may shrink, but never grow past it. Once one is down to 600 lines or fewer, or gone, the test fails until you take it off the list, so the list only gets shorter.

When it fails, split the file along the registries: a feature's code goes in its folder, a message handler in its domain's file, a tick or a key in the feature it's for, and what the page keeps of it in its own slice. Raising a ceiling, or adding a file to the list, only hands the problem to the next person, so the honest way to make room is to split the file. When you shrink a listed file, lower its ceiling to its new length in the same change, so it can't grow back.

Worker worktree creation has its own three-minute checkout timeout; metadata commands retain their 20-second limit. Checkout progress is suppressed, and timeout, output-buffer and Git failures are distinguished. Failed creation rolls back only a newly created branch and checkout still at the requested base, with no modified, untracked or ignored files. Existing paths/branches and any personal work are preserved; removal never uses force. A real delayed smudge-filter checkout in tests/worktrees.test.ts covers the former 20-second cutoff.
42 changes: 36 additions & 6 deletions src/server/worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const BRANCH_PREFIX = 'office/';
/** A fetch this recent is fresh enough for the next worktree: a burst of hires shares one. */
const FETCH_FRESH_MS = 15_000;
const FETCH_TIMEOUT_MS = 15_000;
/** Large checkouts and Git LFS downloads need longer than metadata queries. */
const CREATE_TIMEOUT_MS = 180_000;
/** What the office writes into a worker's workspace (see WorkerInfo.repos), besides the worktrees. */
export const WORKSPACE_FILES = new Set(['AGENTS.md', 'CLAUDE.md']);

Expand Down Expand Up @@ -59,15 +61,40 @@ export class Worktrees {
* returns is relative to `root`.
*/
create(slug: string, sub?: string, root = this.dir): (Required<Omit<WorktreeRef, 'made'>> & { from?: string; note?: string }) | string {
let attempt: { abs: string; branch: string; base: string } | undefined;
try {
const from = this.currentBranch();
const { base, note } = this.startPoint(from);
const rel = path.join(WORKTREES_DIR, slug, sub ?? '');
const branch = `${BRANCH_PREFIX}${slug}`;
this.gitSync(['worktree', 'add', '-b', branch, path.resolve(root, rel), base]);
const abs = path.resolve(root, rel);
const branchExists = !!this.gitSync(['for-each-ref', '--format=%(refname)', `refs/heads/${branch}`]);
if (!existsSync(abs) && !branchExists && within(path.join(real(root), WORKTREES_DIR), real(abs))) attempt = { abs, branch, base };
this.gitSync(['worktree', 'add', '--quiet', '-b', branch, abs, base], this.dir, CREATE_TIMEOUT_MS);
return { path: rel, branch, base, from, note };
} catch (err) {
return `Could not create a git worktree: ${gitError(err)}`;
const cleaned = attempt && this.rollbackCreation(attempt);
const why = (err as { code?: string }).code === 'ETIMEDOUT'
? `Checkout exceeded ${CREATE_TIMEOUT_MS / 1000} seconds. Check Git LFS/network access and retry.`
: gitError(err);
return `Could not create a git worktree: ${why}${cleaned ? ' The unused checkout was cleaned up.' : ' Existing files are preserved.'}`;
}
}

/** Only roll back this attempt's fresh, unchanged checkout; never force removal. */
private rollbackCreation({ abs, branch, base }: { abs: string; branch: string; base: string }): boolean {
try {
if (!existsSync(path.join(abs, '.git'))) return false;
if (this.gitSync(['symbolic-ref', '--short', 'HEAD'], abs) !== branch) return false;
if (this.gitSync(['rev-parse', 'HEAD'], abs) !== base) return false;
// Include ignored files: a Library, personal file or another process's output is work to preserve.
if (this.gitSync(['status', '--porcelain', '--untracked-files=all', '--ignored'], abs)) return false;
this.gitSync(['worktree', 'remove', abs], this.dir, CREATE_TIMEOUT_MS);
// Compare-and-delete so a concurrently advanced branch cannot lose commits.
this.gitSync(['update-ref', '-d', `refs/heads/${branch}`, base]);
return true;
} catch {
return false;
}
}

Expand Down Expand Up @@ -342,8 +369,8 @@ export class Worktrees {
return within(path.join(this.root, WORKTREES_DIR), real(abs));
}

private gitSync(args: string[], cwd = this.dir): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 }).trim();
private gitSync(args: string[], cwd = this.dir, timeout = 20_000): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout }).trim();
}

private async git(args: string[], cwd = this.dir): Promise<string> {
Expand Down Expand Up @@ -372,8 +399,11 @@ export function describeWork(s: WorktreeState): string {

/** The last line git printed, which is the one that says what's wrong. */
export function gitError(err: unknown): string {
const e = err as { stderr?: string; message?: string };
return String(e.stderr || e.message || err).trim().split('\n').filter(Boolean).pop() ?? 'git failed';
const e = err as { stderr?: string; message?: string; code?: string };
if (e.code === 'ETIMEDOUT') return 'Git timed out before finishing';
if (e.code === 'ENOBUFS') return 'Git output exceeded the capture limit';
const lines = String(e.stderr || e.message || err).trim().split(/[\r\n]+/).filter(Boolean);
return lines.find((line) => /^(fatal|error):/.test(line)) ?? lines.pop() ?? 'git failed';
}

function real(p: string): string {
Expand Down
76 changes: 75 additions & 1 deletion tests/worktrees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Changes } from '../src/server/changes.js';
import { Worktrees } from '../src/server/worktrees.js';
import { Worktrees, gitError } from '../src/server/worktrees.js';

/**
* A project cloned from a bare origin, plus a second clone standing in for GitHub: whatever it
Expand Down Expand Up @@ -41,6 +41,80 @@ function fixture(t: { after(fn: () => void): void }) {
return { root, dir, git: (...args: string[]) => run(dir, ...args), commit: (file: string) => commit(dir, file), merge };
}

test('a checkout exceeding the old 20-second limit still creates its worker worktree', (t) => {
const f = fixture(t);
writeFileSync(path.join(f.dir, '.gitattributes'), 'slow.txt filter=slow\n');
writeFileSync(path.join(f.dir, 'slow.txt'), 'worker payload\n');
f.git('add', '.gitattributes', 'slow.txt');
f.git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'slow checkout fixture');
const head = f.git('rev-parse', 'HEAD');
const filter = path.join(f.root, 'slow.cjs');
writeFileSync(filter, "const fs=require('node:fs');const bytes=fs.readFileSync(0);Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,21000);process.stdout.write(bytes);");
f.git('config', 'filter.slow.smudge', `"${process.execPath.replaceAll('\\', '/')}" "${filter.replaceAll('\\', '/')}"`);
f.git('config', 'filter.slow.required', 'true');
const start = Date.now();
const made = new Worktrees(f.dir).create('slow-checkout');
assert.ok(typeof made !== 'string', String(made));
assert.ok(Date.now() - start >= 20_000, 'actually exceeds the previous timeout');
assert.ok(existsSync(path.join(f.dir, made.path, 'slow.txt')));
assert.equal(f.git('rev-parse', 'HEAD'), head, 'main checkout is unchanged');
});

test('git failures report their cause instead of checkout progress', () => {
assert.equal(gitError({ code: 'ETIMEDOUT', stderr: 'Updating files: 96%' }), 'Git timed out before finishing');
assert.equal(gitError({ code: 'ENOBUFS', stderr: 'Updating files: 96%' }), 'Git output exceeded the capture limit');
assert.equal(gitError({ stderr: 'Updating files: 96%\rfatal: LFS download failed\nAdvice follows' }), 'fatal: LFS download failed');
});

for (const contents of ['clean', 'untracked', 'ignored', 'committed'] as const) {
test(`failed creation cleans only its own unused checkout: ${contents}`, (t) => {
const f = fixture(t);
const trees = new Worktrees(f.dir);
const internal = trees as unknown as { gitSync(args: string[], cwd?: string, timeout?: number): string };
const run = internal.gitSync.bind(trees);
const abs = path.join(f.dir, '.agent-office', 'worktrees', 'failed-create');
if (contents === 'ignored') {
const excludes = path.join(f.root, 'excludes');
writeFileSync(excludes, 'personal.txt\n');
f.git('config', 'core.excludesFile', excludes);
}
internal.gitSync = (args, cwd, timeout) => {
const result = run(args, cwd, timeout);
if (args[0] === 'worktree' && args[1] === 'add') {
if (contents !== 'clean') writeFileSync(path.join(abs, 'personal.txt'), 'keep this');
if (contents === 'committed') {
run(['add', 'personal.txt'], abs);
run(['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'personal work'], abs);
}
throw Object.assign(new Error('checkout timed out'), { code: 'ETIMEDOUT' });
}
return result;
};
const result = trees.create('failed-create');
assert.equal(typeof result, 'string');
if (contents === 'clean') {
assert.equal(existsSync(abs), false);
assert.equal(f.git('for-each-ref', '--format=%(refname)', 'refs/heads/office/failed-create'), '');
assert.match(String(result), /cleaned up/);
} else {
assert.ok(existsSync(path.join(abs, 'personal.txt')), 'personal work survives');
assert.ok(f.git('for-each-ref', '--format=%(refname)', 'refs/heads/office/failed-create'));
assert.match(String(result), /preserved/);
}
});
}

test('a name collision never removes the existing worker checkout', (t) => {
const f = fixture(t);
const trees = new Worktrees(f.dir);
const made = trees.create('existing-worker');
assert.ok(typeof made !== 'string');
const personal = path.join(f.dir, made.path, 'personal.txt');
writeFileSync(personal, 'keep this');
assert.equal(typeof trees.create('existing-worker'), 'string');
assert.ok(existsSync(personal));
});

test("a worktree starts from the PR merged on origin, not from the project's stale checkout", async (t) => {
const f = fixture(t);
const stale = f.git('rev-parse', 'HEAD');
Expand Down