Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
62749e4
feat(admin): add Cloudflare dashboard links to town inspector (#1790)
jrf0110 Mar 31, 2026
9c13a62
fix(gastown): add dispatch circuit breaker to prevent infinite retry …
jrf0110 Mar 31, 2026
223731e
feat(beads): add FailureReason type and extend updateBeadStatus() to …
jrf0110 Mar 19, 2026
babf6f0
fix(gastown): re-apply lost empty repo handling and KILOCODE_TOKEN re…
jrf0110 Apr 1, 2026
b5be7a6
fix(gastown): live-push config to running container and add restart b…
jrf0110 Apr 1, 2026
b61e356
fix(gastown): address PR review comments — security, correctness, and UX
jrf0110 Apr 1, 2026
007a5bd
fix(gastown): gate container actions behind effectiveReadOnly
jrf0110 Apr 1, 2026
db34f85
feat(gastown): terminal fullscreen toggle and product telemetry (#1575)
jrf0110 Mar 31, 2026
9940035
feat(gastown): suppress dispatch_agent actions when town is draining …
jrf0110 Apr 1, 2026
4e50a2b
feat(container): add drainAll() to process-manager for graceful conta…
jrf0110 Apr 1, 2026
83fb580
feat(container): use drainAll in SIGTERM handler for graceful eviction
jrf0110 Apr 1, 2026
8e3d2af
style: fix formatting from oxfmt
jrf0110 Apr 1, 2026
c2e2217
fix(gastown): record status_changed events for all failed transitions…
jrf0110 Apr 1, 2026
efb1771
fix(gastown): route status changes through updateBeadStatus in update…
jrf0110 Apr 1, 2026
df8abf5
fix(gastown): address second round of PR review comments
jrf0110 Apr 1, 2026
eed3c4f
chore(gastown): bump max_instances from 500 to 600 (#1853)
jrf0110 Apr 1, 2026
0fd8c89
fix(gastown): resolve CI failures — lint, typecheck, and formatting
jrf0110 Apr 1, 2026
69cfbe6
fix(gastown): add Zod defaults for dispatch_attempts columns on beads…
jrf0110 Apr 1, 2026
fe8abd4
fix(gastown): add reconciler rule for open beads with stale assignee
jrf0110 Apr 1, 2026
17bf0db
fix(gastown): use bare column names in Rule 1b subquery
jrf0110 Apr 1, 2026
7d936fd
docs(gastown): add no-alias rule for SQL queries, fix Rule 1b
jrf0110 Apr 1, 2026
4201f93
fix(gastown): use SIGTERM (stop) instead of SIGKILL (destroy) for con…
jrf0110 Apr 1, 2026
4ae6c68
feat(gastown): add separate Graceful Stop and Destroy Container buttons
jrf0110 Apr 1, 2026
71ad75f
fix(gastown): improve drain Phase 2 logging for nudge debugging
jrf0110 Apr 1, 2026
dfa3965
perf(gastown): deduplicate and guard wasteful queries in TownDO alarm…
jrf0110 Apr 1, 2026
9f1ca1e
fix(gastown): fix graceful drain — idle timer, starting agents, evict…
jrf0110 Apr 2, 2026
5f577f2
fix(gastown): restrict container restart/destroy to org owners, skip …
jrf0110 Apr 2, 2026
f218ed8
fix(gastown): report evicted agents as completed to TownDO in Phase 4
jrf0110 Apr 2, 2026
36e3261
fix(gastown): reset evicted beads to open immediately instead of wait…
jrf0110 Apr 2, 2026
706994e
fix(gastown): clear stale assignee on evicted beads, bump max_instanc…
jrf0110 Apr 2, 2026
f051d46
fix(gastown): update dev GASTOWN_API_URL to port 8803
jrf0110 Apr 2, 2026
27fabe1
fix(gastown): skip stale-heartbeat and GUPP checks during container d…
jrf0110 Apr 2, 2026
267828b
fix(gastown): use nudge-aware drain idle timeout instead of idle count
jrf0110 Apr 2, 2026
d39ef89
fix(gastown): set agent status to running before initial prompt, nudg…
jrf0110 Apr 2, 2026
6d9cea8
feat(gastown): simplify drain to wait-only (no nudge), add drain stat…
jrf0110 Apr 2, 2026
808f79a
fix(gastown): town containers never idle — mayor holds alarm, health …
jrf0110 Apr 2, 2026
5fcfc6e
fix(gastown): skip nudge fetch during drain, add 10s timeout to fetch…
jrf0110 Apr 2, 2026
7eea0d3
fix(gastown): add spacing around drain status banner
jrf0110 Apr 2, 2026
bf4aee0
fix(gastown): use padding wrapper instead of margin on drain banner t…
jrf0110 Apr 2, 2026
a9db6d1
fix(gastown): clear drain flag as soon as all non-mayor agents are idle
jrf0110 Apr 2, 2026
7f82093
fix(gastown): detect container restart via heartbeat instance ID, cle…
jrf0110 Apr 2, 2026
de7ae34
fix(gastown): drain Phase 2 exits early when all agents have idle tim…
jrf0110 Apr 2, 2026
63ff892
fix(gastown): address PR review — stream race, mayor watermark, admin…
jrf0110 Apr 2, 2026
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
1 change: 1 addition & 0 deletions cloudflare-gastown/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
- `${beads}` → bare table name. Use for `FROM`, `INSERT INTO`, `DELETE FROM`.
- `${beads.columns.status}` → bare column name. Use for `SET` clauses and `INSERT` column lists where the table is already implied.
- `${beads.status}` → qualified `table.column`. Use for `SELECT`, `WHERE`, `JOIN ON`, `ORDER BY`, and anywhere a column could be ambiguous.
- **Do not alias tables in SQL queries.** Always use the full table name and the qualified `${table.column}` interpolator. Aliases like `FROM beads b` combined with the qualified interpolator produce double-qualified names (`b.beads.bead_id`) that SQLite rejects. If a self-join requires disambiguation, use a raw string alias only for the second copy and reference its columns with `${table.columns.col}` (bare) prefixed manually.
- Prefer static queries over dynamically constructed ones. Move conditional logic into the query itself using SQL constructs like `COALESCE`, `CASE`, `NULLIF`, or `WHERE (? IS NULL OR col = ?)` patterns so the full query is always visible as a single readable string.
- Always parse query results with the Zod `Record` schemas from `db/tables/*.table.ts`. Never use ad-hoc `as Record<string, unknown>` casts or `String(row.col)` to extract fields — use `.pick()` for partial selects and `.array()` for lists, e.g. `BeadRecord.pick({ bead_id: true }).array().parse(rows)`. This keeps row parsing type-safe and co-located with the schema definition.
- When a column has a SQL `CHECK` constraint that restricts it to a set of values (i.e. an enum), mirror that in the Record schema using `z.enum()` rather than `z.string()`, e.g. `role: z.enum(['polecat', 'refinery', 'mayor', 'witness'])`.
Expand Down
41 changes: 41 additions & 0 deletions cloudflare-gastown/container/src/completion-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@

import type { ManagedAgent } from './types';

/**
* Notify the TownDO that the mayor has finished processing a prompt and
* is now waiting for user input. This lets the TownDO transition the
* mayor from "working" to "waiting", which drops the alarm to the idle
* cadence and stops health-check pings that reset the container's
* sleepAfter timer.
*
* Best-effort: errors are logged but do not propagate.
*/
export async function reportMayorWaiting(agent: ManagedAgent): Promise<void> {
const apiUrl = agent.gastownApiUrl;
const authToken =
process.env.GASTOWN_CONTAINER_TOKEN ?? agent.gastownContainerToken ?? agent.gastownSessionToken;
if (!apiUrl || !authToken) {
console.warn(
`Cannot report mayor ${agent.agentId} waiting: no API credentials on agent record`
);
return;
}

const url = `${apiUrl}/api/towns/${agent.townId}/rigs/${agent.rigId}/agents/${agent.agentId}/waiting`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ agentId: agent.agentId, firedAt: Date.now() }),
});

if (!response.ok) {
console.warn(
`Failed to report mayor ${agent.agentId} waiting: ${response.status} ${response.statusText}`
);
}
} catch (err) {
console.warn(`Error reporting mayor ${agent.agentId} waiting:`, err);
}
}

/**
* Notify the Rig DO that an agent session has completed or failed.
* Best-effort: errors are logged but do not propagate.
Expand Down
131 changes: 89 additions & 42 deletions cloudflare-gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
activeServerCount,
getUptime,
stopAll,
drainAll,
isDraining,
getAgentEvents,
registerEventSink,
} from './process-manager';
import { startHeartbeat, stopHeartbeat } from './heartbeat';
import { startHeartbeat, stopHeartbeat, notifyContainerReady } from './heartbeat';
import { pushContext as pushDashboardContext } from './dashboard-context';
import { mergeBranch, setupRigBrowseWorktree } from './git-manager';
import {
Expand Down Expand Up @@ -46,6 +48,53 @@ export function getCurrentTownConfig(): Record<string, unknown> | null {
return lastKnownTownConfig;
}

/**
* Sync config-derived env vars from the last-known town config into
* process.env. Safe to call at any time — no-ops when no config is cached.
*/
function syncTownConfigToProcessEnv(): void {
const cfg = getCurrentTownConfig();
if (!cfg) return;

const CONFIG_ENV_MAP: Array<[string, string]> = [
['github_cli_pat', 'GITHUB_CLI_PAT'],
['git_author_name', 'GASTOWN_GIT_AUTHOR_NAME'],
['git_author_email', 'GASTOWN_GIT_AUTHOR_EMAIL'],
['kilocode_token', 'KILOCODE_TOKEN'],
];
for (const [cfgKey, envKey] of CONFIG_ENV_MAP) {
const val = cfg[cfgKey];
if (typeof val === 'string' && val) {
process.env[envKey] = val;
} else {
delete process.env[envKey];
}
}

const gitAuth = cfg.git_auth;
if (typeof gitAuth === 'object' && gitAuth !== null) {
const auth = gitAuth as Record<string, unknown>;
for (const [authKey, envKey] of [
['github_token', 'GIT_TOKEN'],
['gitlab_token', 'GITLAB_TOKEN'],
['gitlab_instance_url', 'GITLAB_INSTANCE_URL'],
] as const) {
const val = auth[authKey];
if (typeof val === 'string' && val) {
process.env[envKey] = val;
} else {
delete process.env[envKey];
}
}
}

if (cfg.disable_ai_coauthor) {
process.env.GASTOWN_DISABLE_AI_COAUTHOR = '1';
} else {
delete process.env.GASTOWN_DISABLE_AI_COAUTHOR;
}
}

export const app = new Hono();

// Parse and validate town config from X-Town-Config header (sent by TownDO on
Expand Down Expand Up @@ -92,11 +141,21 @@ app.use('*', async (c, next) => {

// GET /health
app.get('/health', c => {
// When the TownDO is draining, it passes the drain nonce and town
// ID via headers so idle containers (no running agents) can
// acknowledge readiness and clear the drain flag.
const drainNonce = c.req.header('X-Drain-Nonce');
const townId = c.req.header('X-Town-Id');
if (drainNonce && townId) {
void notifyContainerReady(townId, drainNonce);
}

const response: HealthResponse = {
status: 'ok',
agents: activeAgentCount(),
servers: activeServerCount(),
uptime: getUptime(),
draining: isDraining() || undefined,
};
return c.json(response);
});
Expand Down Expand Up @@ -133,8 +192,23 @@ app.post('/refresh-token', async c => {
return c.json({ refreshed: true });
});

// POST /sync-config
// Push config-derived env vars from X-Town-Config into process.env on
// the running container. Called by TownDO.syncConfigToContainer() after
// persisting env vars to DO storage, so the live process picks up
// changes (e.g. refreshed KILOCODE_TOKEN) without a container restart.
app.post('/sync-config', async c => {
syncTownConfigToProcessEnv();
return c.json({ synced: true });
});

// POST /agents/start
app.post('/agents/start', async c => {
if (isDraining()) {
console.warn('[control-server] /agents/start: rejected — container is draining');
return c.json({ error: 'Container is draining, cannot start new agents' }, 503);
}

const body: unknown = await c.req.json().catch(() => null);
const parsed = StartAgentRequest.safeParse(body);
if (!parsed.success) {
Expand Down Expand Up @@ -214,45 +288,7 @@ app.patch('/agents/:agentId/model', async c => {
// Sync config-derived env vars from X-Town-Config into process.env so
// the SDK server restart picks up fresh tokens and git identity.
// The middleware already parsed the header into lastKnownTownConfig.
const cfg = getCurrentTownConfig();
if (cfg) {
const CONFIG_ENV_MAP: Array<[string, string]> = [
['github_cli_pat', 'GITHUB_CLI_PAT'],
['git_author_name', 'GASTOWN_GIT_AUTHOR_NAME'],
['git_author_email', 'GASTOWN_GIT_AUTHOR_EMAIL'],
];
for (const [cfgKey, envKey] of CONFIG_ENV_MAP) {
const val = cfg[cfgKey];
if (typeof val === 'string' && val) {
process.env[envKey] = val;
} else {
delete process.env[envKey];
}
}
// git_auth tokens
const gitAuth = cfg.git_auth;
if (typeof gitAuth === 'object' && gitAuth !== null) {
const auth = gitAuth as Record<string, unknown>;
for (const [authKey, envKey] of [
['github_token', 'GIT_TOKEN'],
['gitlab_token', 'GITLAB_TOKEN'],
['gitlab_instance_url', 'GITLAB_INSTANCE_URL'],
] as const) {
const val = auth[authKey];
if (typeof val === 'string' && val) {
process.env[envKey] = val;
} else {
delete process.env[envKey];
}
}
}
// disable_ai_coauthor
if (cfg.disable_ai_coauthor) {
process.env.GASTOWN_DISABLE_AI_COAUTHOR = '1';
} else {
delete process.env.GASTOWN_DISABLE_AI_COAUTHOR;
}
}
syncTownConfigToProcessEnv();

await updateAgentModel(
agentId,
Expand Down Expand Up @@ -723,15 +759,26 @@ export function startControlServer(): void {
startHeartbeat(apiUrl, authToken);
}

// Handle graceful shutdown
// Handle graceful shutdown (immediate, no drain — used by SIGINT for dev)
const shutdown = async () => {
console.log('Shutting down control server...');
stopHeartbeat();
await stopAll();
process.exit(0);
};

process.on('SIGTERM', () => void shutdown());
process.on(
'SIGTERM',
() =>
void (async () => {
console.log('[control-server] SIGTERM received — starting graceful drain...');
stopHeartbeat();
await drainAll();
await stopAll();
process.exit(0);
})()
);

process.on('SIGINT', () => void shutdown());

// Track connected WebSocket clients with optional agent filter
Expand Down
68 changes: 67 additions & 1 deletion cloudflare-gastown/container/src/git-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,45 @@ async function cloneRepoInner(
`Cloning repo for rig ${options.rigId}: hasAuth=${hasAuth} envKeys=[${Object.keys(options.envVars ?? {}).join(',')}]`
);

// Omit --branch: on empty repos (no commits) the default branch doesn't
// exist yet, so `git clone --branch <branch>` would fail with
// "Remote branch <branch> not found in upstream origin".
await mkdir(dir, { recursive: true });
await exec('git', ['clone', '--no-checkout', '--branch', options.defaultBranch, authUrl, dir]);
await exec('git', ['clone', '--no-checkout', authUrl, dir]);
await configureRepoCredentials(dir, options.gitUrl, options.envVars);

// Detect empty repo: git rev-parse HEAD fails when there are no commits.
const isEmpty = await exec('git', ['rev-parse', 'HEAD'], dir)
.then(() => false)
.catch(() => true);

if (isEmpty) {
console.log(`Detected empty repo for rig ${options.rigId}, creating initial commit`);
// Create an initial empty commit so branches/worktrees can be created.
// Use -c flags for user identity (the repo has no config yet and the
// container may not have GIT_AUTHOR_NAME set).
await exec(
'git',
[
'-c',
'user.name=Gastown',
'-c',
'user.email=gastown@kilo.ai',
'commit',
'--allow-empty',
'-m',
'Initial commit',
],
dir
);
await exec('git', ['push', 'origin', `HEAD:${options.defaultBranch}`], dir);
// Best-effort: set remote HEAD so future operations know the default branch
await exec('git', ['remote', 'set-head', 'origin', options.defaultBranch], dir).catch(() => {});
// Fetch so origin/<defaultBranch> ref is available locally
await exec('git', ['fetch', 'origin'], dir);
console.log(`Created initial commit on empty repo for rig ${options.rigId}`);
}

console.log(`Cloned repo for rig ${options.rigId}`);
return dir;
}
Expand All @@ -303,6 +339,18 @@ async function createWorktreeInner(options: WorktreeOptions): Promise<string> {
return dir;
}

// Verify the repo has at least one commit. If cloneRepoInner's initial
// commit push failed, there's no HEAD and we can't create branches.
const hasHead = await exec('git', ['rev-parse', '--verify', 'HEAD'], repo)
.then(() => true)
.catch(() => false);

if (!hasHead) {
throw new Error(
`Cannot create worktree: repo has no commits. Push an initial commit first or re-connect the rig.`
);
}

// When a startPoint is provided (e.g. a convoy feature branch), create
// the new branch from that ref so the agent begins with the latest
// merged work from upstream. Without a startPoint, try to track the
Expand Down Expand Up @@ -398,6 +446,24 @@ async function setupBrowseWorktreeInner(rigId: string, defaultBranch: string): P
return browseDir;
}

// Check whether origin/<defaultBranch> exists. On a repo that was just
// initialized with an empty commit in cloneRepoInner the ref should
// exist, but if the push failed (network, permissions) it may not.
const hasRemoteBranch = await exec(
'git',
['rev-parse', '--verify', `origin/${defaultBranch}`],
repo
)
.then(() => true)
.catch(() => false);

if (!hasRemoteBranch) {
console.log(
`Skipping browse worktree for rig ${rigId}: origin/${defaultBranch} not found (repo may be empty), will create on next fetch`
);
return browseDir;
}

// Create a worktree on the default branch for browsing.
// Force-create (or reset) the tracking branch to origin/<defaultBranch>
// so a recreated browse worktree always starts from the latest remote
Expand Down
Loading
Loading