Public: anyone can read this; the crew can change it.

Archive, part 2b: the crew starts no new run for an archived repository, and says why in one line #2466

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2455-archive-part-2b-the-crew-starts-no-new-r → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/2455-archive-part-2b-the-crew-starts-no-new-r was merged into main as edcf588.

Review

Sign in to review this change.
18 files changed +128 −13
README.md +7 −2
@@ -307,8 +307,13 @@ An admin puts a repository away from **Settings → Archive**, with
307307 else gets 403), or in the terminal with `a` on the About card (`I`). The
308308 repository page and the card then say when it was archived, and an admin gets
309309 a Wake button or the same key to undo it. Archiving twice keeps the first date.
310 It does not yet block anything: clone, push and links keep working, and the
311 repository stays in every list.
310 An archived repository starts no new crew run. Assigning an issue to an agent,
311 queueing a run again, asking the crew to propose a resolution, redirecting a
312 wave and converging a fork each answer "This repository is archived; wake it to
313 start a run."; the API answers 409 `archived`, and the terminal and MCP show
314 the same line. The autonomy tick skips it. A run already going finishes, and
315 Wake undoes all of it. Clone, push and links keep working, and the repository
316 stays in every list.
312317
313318 ## Health
314319
src/actions/follow.ts +3 −1
@@ -9,7 +9,7 @@ import { NOT_A_MEMBER } from "../core/roles.ts";
99 import type { App } from "../app.ts";
1010 import { DIRECTION_NOTE } from "../crew/direct.ts";
1111 import { convergeFork, type Converge } from "../crew/converge.ts";
12 import { canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts";
12 import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts";
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { chainOfRepo, severChain } from "../spawn/chain.ts";
@@ -107,6 +107,8 @@ export function convergeWords(
107107 return { tone: "danger", message: DIRECTION_NOTE };
108108 case "not_member":
109109 return { tone: "danger", message: `Not started: ${NOT_A_MEMBER}.` };
110 case "archived":
111 return { tone: "danger", message: ARCHIVED_WHY };
110112 case "ceiling":
111113 return {
112114 tone: "danger",
src/actions/issues.ts +3 −1
@@ -295,7 +295,7 @@ export function assignIssue(
295295 variantGroup?: string,
296296 ): Result<
297297 Issue,
298 AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified" | "not_member"
298 AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified" | "not_member" | "archived"
299299 > {
300300 const writer = requireWriter(app, actor, repo);
301301 if (!writer.ok) return writer;
@@ -316,6 +316,8 @@ export function assignIssue(
316316 // ...and only a member's allowance pays for it.
317317 if (assignee.kind === "agent" && !requireAbility(app, writer.value, "run_crew").ok)
318318 return err("not_member");
319 // Assigning an agent starts a run, and an archived repository starts none.
320 if (assignee.kind === "agent" && repo.archivedAt !== null) return err("archived");
319321 }
320322 const updated = setAssignee(app.db, issue.id, assignee?.id ?? null);
321323 if (!updated) return ok(issue);
src/actions/proposals.ts +2 −1
@@ -12,7 +12,7 @@
1212 import type { App } from "../app.ts";
1313 import { listCrewRoles } from "../core/agents.ts";
1414 import { getProposal, moveProposal, type Proposal, type ProposalState } from "../core/proposals.ts";
15 import { getRepoById, ownerFactRepoIds, canRead, type Repo } from "../core/repos.ts";
15 import { ARCHIVED_WHY, getRepoById, ownerFactRepoIds, canRead, type Repo } from "../core/repos.ts";
1616 import type { User } from "../core/users.ts";
1717 import { ensureDirectionIssue } from "../crew/autonomy.ts";
1818 import { queueAgentRun } from "../crew/dispatch.ts";
@@ -94,6 +94,7 @@ export function redirectProposal(
9494 const { proposal, repo } = found.value;
9595 // The run it starts is a member's: refused before anything is written.
9696 if (!requireAbility(app, found.value.actor, "run_crew").ok) return fail("forbidden", NOT_A_MEMBER);
97 if (repo.archivedAt !== null) return fail("invalid", ARCHIVED_WHY);
9798 const planner = listCrewRoles(app.db, repo.id).find((c) => c.role === "planner")?.agent;
9899 if (planner === undefined) return fail("invalid", "no planner on this crew to hand it to");
99100 const direction = ensureDirectionIssue(app, found.value.actor, repo);
src/actions/runs.ts +2 −1
@@ -201,7 +201,7 @@ export function requeueRepoRun(
201201 actor: User | undefined,
202202 repo: Repo,
203203 runId: string,
204 ): Result<Run, AccessError | "not_agent" | "not_finished" | "no_task" | "not_queued" | "not_member"> {
204 ): Result<Run, AccessError | "not_agent" | "not_finished" | "no_task" | "not_queued" | "not_member" | "archived"> {
205205 const writer = requireWriter(app, actor, repo);
206206 if (!writer.ok) return writer;
207207 // A run costs money; like assigning, queueing one is a person's call.
@@ -209,6 +209,7 @@ export function requeueRepoRun(
209209 const run = getRun(app.db, runId);
210210 if (!run || run.repoId !== repo.id) return err("not_found");
211211 if (run.kind !== "agent") return err("not_agent");
212 if (repo.archivedAt !== null) return err("archived");
212213 const member = requireAbility(app, writer.value, "run_crew");
213214 if (!member.ok) return member;
214215 if (run.state !== "cancelled" && run.state !== "failed")
src/core/repos.ts +3
@@ -1049,6 +1049,9 @@ export interface RepoSettings {
10491049 plansNeedNod?: boolean;
10501050 }
10511051
1052 /** The one line said wherever the crew declines to start a run here. */
1053 export const ARCHIVED_WHY = "This repository is archived; wake it to start a run.";
1054
10521055 /** Update the mutable repo fields, leaving unspecified ones untouched. */
10531056 export function updateRepoSettings(
10541057 db: Database,
src/crew/autonomy.ts +2
@@ -1090,6 +1090,8 @@ export async function autonomyTick(
10901090 ): Promise<void> {
10911091 if (crewOffInPreview()) return;
10921092 for (const repo of listAutonomousRepos(app.db)) {
1093 // An archived repository is put away: the crew starts nothing in it.
1094 if (repo.archivedAt !== null) continue;

No test covers this skip. Add one: an archived autonomous repository whose tick queues nothing.

10931095 try {
10941096 void startSweep(app, repo, sweep);
10951097 // Quick, and before the picks: a pull main moved under is brought up
src/crew/converge.ts +5 −1
@@ -70,7 +70,9 @@ export type Converge =
7070 | { kind: "forbidden" }
7171 /** A guest: the run is a member's to start, refused before anything is written. */
7272 | { kind: "not_member" }
73 | { kind: "ceiling" };
73 | { kind: "ceiling" }
74 /** The fork is archived: no issue, no run, until it is woken. */
75 | { kind: "archived" };
7476
7577 /** The fork's open converge issue, if the parent's updates are in hand. */
7678 export function convergeIssue(
@@ -149,6 +151,8 @@ export async function convergeFork(
149151 return { kind: "forbidden" };
150152 if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok)
151153 return { kind: "not_member" };
outdated

For a person who asked, this is silent. 'nothing' reads as 'already converged'. Return a distinct kind (for example archived) and have the page, API and tty callers say ARCHIVED_WHY. It also needs a test.

154 // An archived fork is put away: no issue, no run, and it says so.
155 if (fork.archivedAt !== null) return { kind: "archived" };
152156 const relation = await forkRelation(app, fork, parent);
153157 if (relation === undefined || relation.state !== "diverged" || relation.clean)
154158 return { kind: "nothing", relation };
src/crew/dispatch.ts +5
@@ -290,6 +290,11 @@ export function queueAgentRun(
290290 ): Run | undefined {
291291 // A suspended or removed owner's repositories run nothing, whoever asks.
292292 if (!accountActive(app.db, repo.ownerId)) return undefined;
293 // An archived repository starts nothing new; a run carrying on (`again`)
294 // is one already going, and finishes. A first run
295 // (`attempt` 0, as a conflict or converge run is queued) is new.
outdated

This refuses silently. Callers like assign and planner feedback get undefined and say nothing; surface the archived line at those boundaries.

outdated

High: again is also passed with attempt: 0 by sweepConflicts (autonomy.ts:1517) and convergeFork (converge.ts:194) for brand-new runs, so both start runs in an archived repository. Gate on an explicit carry-on marker (or attempt > 0), not on again's presence, and skip archived repositories in autonomyTick/convergeFork before they write anything.

296 if (repo.archivedAt !== null && (again === undefined || again.attempt === 0))

This catches every new queue, but not a run that was queued before Archive and hasn't started yet. The runner will still start it. Check again where the runner picks up a queued run: attempt 0 in an archived repository means cancel it with ARCHIVED_WHY.

297 return undefined;
293298 // A guest's word starts no run (a comment is still a comment); the
294299 // actions that start one by name refuse out loud before they get here.
295300 const asker = actorId === undefined ? undefined : getUserById(app.db, actorId);
src/crew/proposal.ts +5 −2
@@ -10,7 +10,7 @@ import { requireAbility } from "../actions/roles.ts";
1010 import type { App } from "../app.ts";
1111 import { getIssueById, listComments } from "../core/issues.ts";
1212 import { pullDir, pullStatus, type Pull } from "../core/pulls.ts";
13 import { canWrite, type Repo } from "../core/repos.ts";
13 import { ARCHIVED_WHY, canWrite, type Repo } from "../core/repos.ts";
1414 import { activeRunFor, listRuns, type Run } from "../core/runs.ts";
1515 import type { User } from "../core/users.ts";
1616 import { readConflictedFiles } from "../git/conflict-file.ts";
@@ -27,7 +27,8 @@ export type ProposeError =
2727 | "no_agent"
2828 | "busy"
2929 | "not_member"
30 | "not_queued";
30 | "not_queued"
31 | "archived";
3132
3233 /** What the brief carries at most: a hand's worth, never a whole history. */
3334 const MAX_COMMITS = 20;
@@ -49,6 +50,7 @@ export async function proposeResolution(
4950 ): Promise<Result<Run, ProposeError>> {
5051 if (!canWrite(app.db, repo, actor)) return err("forbidden");
5152 if (pull.state !== "open") return err("not_open");
53 if (repo.archivedAt !== null) return err("archived");
5254 if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok) return err("not_member");
5355 const issue = getIssueById(app.db, pull.id);
5456 const agent = issue === undefined ? undefined : agentFor(app, issue);
@@ -218,6 +220,7 @@ export const PROPOSE_WHY: Record<ProposeError, string> = {
218220 no_agent: "No agent holds this pull, so the crew has no hand to ask.",
219221 busy: "The crew is already working on this pull.",
220222 not_member: "Starting a run is for members: the owner has not made this account a member.",
223 archived: ARCHIVED_WHY,
221224 not_queued: "The crew could not take it on right now (its ceiling or breaker); try again later.",
222225 };
223226
src/fleet/verbs.ts +2
@@ -33,6 +33,7 @@ import type { FleetValue } from "../core/fleet_ops.ts";
3333 import { findPillar, isStructural, type Pillars } from "../core/pillars.ts";
3434 import { getIssue, type Issue } from "../core/issues.ts";
3535 import {
36 ARCHIVED_WHY,
3637 canRead,
3738 isAutonomy,
3839 type Repo,
@@ -122,6 +123,7 @@ const WORDS: Record<string, string> = {
122123 not_agent: "only an agent run is queued again",
123124 not_finished: "only a cancelled or failed run is queued again",
124125 no_task: "its issue is closed or its agent is gone",
126 archived: ARCHIVED_WHY,
125127 not_queued: "the crew would not take it now (already queued, parked, or at the ceiling)",
126128 unknown_branch: "unknown branch",
127129 };
src/http/api.ts +1
@@ -2310,6 +2310,7 @@ export function apiRoutes(router: Router<Handler>): void {
23102310 if (parent === undefined)
23112311 return apiError("not_a_fork", 404, "this repository follows nothing");
23122312 const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user);
2313 if (outcome.kind === "archived") return respondAccess("archived", 409);
23132314 const status =
23142315 outcome.kind === "queued"
23152316 ? 202
src/http/mcp.ts +2
@@ -104,6 +104,7 @@ import { pillarView } from "../core/pillars.ts";
104104 import { askToLiveFor } from "../web/ask_to_live.ts";
105105 import { getAppByRepo } from "../core/apps.ts";
106106 import {
107 ARCHIVED_WHY,
107108 canRead,
108109 canWrite,
109110 forkParentFor,
@@ -1523,6 +1524,7 @@ const TOOLS: readonly Tool[] = [
15231524 const outcome = await convergeFork(ctx.app, repo, parent, user);
15241525 if (outcome.kind === "forbidden") return bad("forbidden", DIRECTION_NOTE);
15251526 if (outcome.kind === "not_member") return bad("not_member", NOT_A_MEMBER);
1527 if (outcome.kind === "archived") return bad("archived", ARCHIVED_WHY);
15261528 return good({
15271529 kind: outcome.kind,
15281530 issue: "issue" in outcome ? outcome.issue.number : null,
src/http/respond.ts +2
@@ -6,6 +6,7 @@
66
77 import { bodyCapFor, readBounded } from "./limits.ts";
88 import { scopeAllows, type TokenScope } from "../core/tokens.ts";
9 import { ARCHIVED_WHY } from "../core/repos.ts";
910 import { NOT_A_MEMBER } from "../core/roles.ts";
1011 import { NEW_ACCOUNT_GUARD_MESSAGE } from "../core/capacity.ts";
1112 import type { User } from "../core/users.ts";
@@ -155,6 +156,7 @@ export function respondAccess(error: string, status: number): Response {
155156 // clients see no difference between the trigger and the setting.
156157 if (error === "refused")
157158 return json({ error, message: NEW_ACCOUNT_GUARD_MESSAGE }, 403);
159 if (error === "archived") return json({ error, message: ARCHIVED_WHY }, 409);
158160 if (error === "not_member")
159161 return json({ error, message: NOT_A_MEMBER }, 403);
160162 if (error === "unauthorized") return unauthorized();
src/tui/screens/common.ts +2 −1
@@ -11,7 +11,7 @@ import { NOT_A_MEMBER } from "../../core/roles.ts";
1111 import type { Ctx, Screen } from "../app/app.ts";
1212 import { binding, type Binding } from "../app/keys.ts";
1313 import type { KeyEvent } from "../term/input.ts";
14 import type { RepoCounts } from "../../core/repos.ts";
14 import { ARCHIVED_WHY, type RepoCounts } from "../../core/repos.ts";
1515 import { SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts";
1616 import type { Session } from "../session.ts";
1717 import { G, Screen as Surface, type Rect } from "../term/screen.ts";
@@ -815,6 +815,7 @@ export function slug(path: string): string {
815815 // ---- errors and time ----
816816
817817 const MESSAGES: Record<string, string> = {
818 archived: ARCHIVED_WHY,
818819 not_member: NOT_A_MEMBER,
819820 not_found: "not found",
820821 signup_closed: SIGNUP_CLOSED_MESSAGE,
src/web/pages/thread.ts +2 −1
@@ -38,7 +38,7 @@ import {
3838 MAX_BODY,
3939 MAX_TITLE,
4040 } from "../../core/issues.ts";
41 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
41 import { ARCHIVED_WHY, canRead, canWrite, type Repo } from "../../core/repos.ts";
4242 import { inlineIcons, type IconName as Glyph } from "../icons.ts";
4343 import {
4444 getUserById,
@@ -725,6 +725,7 @@ export async function assigneeSubmit(
725725 assignee_cannot_read: `${name} cannot see this repository, so cannot be assigned to it.`,
726726 agent_not_on_crew: `${name} is not on this repository's crew. Add it under Settings → Crew first.`,
727727 forbidden: "You need write access to assign.",
728 archived: ARCHIVED_WHY,
728729 };
729730 return flashTo(ctx, dest, {
730731 tone: "danger",
test/crew.test.ts +58 −2
@@ -11,6 +11,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1111 import { existsSync, readFileSync } from "node:fs";
1212 import { join } from "node:path";
1313 import type { App } from "../src/app.ts";
14 import { cancelRepoRun, requeueRepoRun } from "../src/actions/runs.ts";
15 import { respondAccess } from "../src/http/respond.ts";
16 import { errorText } from "../src/tui/screens/common.ts";
17 import { queueAgentRun } from "../src/crew/dispatch.ts";
18 import { autonomyTick } from "../src/crew/autonomy.ts";
19 import { proposeResolution } from "../src/crew/proposal.ts";
20 import { convergeFork } from "../src/crew/converge.ts";
21 import { convergeWords } from "../src/actions/follow.ts";
1422 import { assignIssue, openIssue } from "../src/actions/issues.ts";
1523 import { sandboxAvailable } from "../src/crew/sandbox.ts";
1624 import type { CrewHandle } from "../src/crew/dispatch.ts";
@@ -20,8 +28,8 @@ import { listPulls } from "../src/core/pulls.ts";
2028 import { resolveOid } from "../src/git/merge.ts";
2129 import { listPaths } from "../src/git/read.ts";
2230 import { repoDir } from "../src/git/repos.ts";
23 import { getForkOf, type Repo } from "../src/core/repos.ts";
24 import type { Run } from "../src/core/runs.ts";
31 import { getForkOf, ARCHIVED_WHY, updateRepoSettings, type Repo } from "../src/core/repos.ts";
32 import { listRuns, type Run } from "../src/core/runs.ts";
2533 import type { User } from "../src/core/users.ts";
2634 import type { Servers } from "../src/http/server.ts";
2735 import { readRunLog } from "../src/runs/log.ts";
@@ -186,4 +194,52 @@ describe("the crew, with a stand-in Claude Code", () => {
186194 ),
187195 ).toBe(true);
188196 }, 60_000);
197
198 test("an archived repository starts no new run, says why, and starts one again after Wake", async () => {
199 const issue = openIssue(app, alice, repo, { title: "Mow", body: "The lawn." });
200 expect(issue.ok).toBe(true);
201 if (!issue.ok) return;
202 const agent = getAgentByName(app.db, "bot")!;
203 // A finished run of this test's own, to be queued again.
204 const own = queueAgentRun(app, repo, issue.value, agent, "assigned");
205 expect(own).toBeDefined();
outdated

finished! assumes an earlier test left a finished agent run; if test order or filtering changes this throws. Create the run in this test.

206 cancelRepoRun(app, alice, repo, own!.id);
207 const archived = updateRepoSettings(app.db, repo, { archived: true });
208 expect(archived.archivedAt).not.toBeNull();
209 expect(queueAgentRun(app, archived, issue.value, agent, "assigned")).toBeUndefined();
210 const refused = requeueRepoRun(app, alice, archived, own!.id);
211 expect(refused.ok ? "" : refused.error).toBe("archived");
212 // Asking for a conflict resolution starts a run too: refused the same way.
213 const pull = { state: "open", id: "none", number: 1 } as Parameters<typeof proposeResolution>[3];
214 expect(await proposeResolution(app, alice, archived, pull)).toMatchObject({ ok: false, error: "archived" });
215 const assigned = assignIssue(app, alice, archived, issue.value, bot);
216 expect(assigned.ok ? "" : assigned.error).toBe("archived");
217 expect(respondAccess("archived", 422).status).toBe(409);
outdated

Low: the test name says a run starts again after Wake, but if (run) never asserts it. Use expect(run).toBeDefined() first. Also add the conflict-reason case with again: { attempt: 0 } on an archived repository; it fails today.

218 expect(errorText("archived")).toBe(ARCHIVED_WHY);
219 // A fresh run the crew queues on its own (attempt 0) is new too.
220 const auto = { ...archived, autonomy: "autonomous" as const };
221 const fresh = { notBefore: new Date().toISOString(), attempt: 0, summary: "main moved" };
222 expect(queueAgentRun(app, auto, issue.value, agent, "conflict", undefined, fresh)).toBeUndefined();
223 // Converge on an archived fork says so, and opens nothing.
224 const converged = await convergeFork(app, archived, archived, undefined);
225 expect(converged.kind).toBe("archived");
226 expect(convergeWords(converged, "p/q").message).toBe(ARCHIVED_WHY);
227 // An archived autonomous repository's tick queues nothing.
228 const before = listRuns(app.db, archived.id, { limit: 200 }).length;
229 updateRepoSettings(app.db, archived, { autonomy: "autonomous" });
230 await autonomyTick(app);
231 updateRepoSettings(app.db, archived, { autonomy: "off" });
232 expect(listRuns(app.db, archived.id, { limit: 200 }).length).toBe(before);
233 // Taking someone off is not starting a run.
234 expect(assignIssue(app, alice, archived, issue.value, null).ok).toBe(true);
235 const woken = updateRepoSettings(app.db, archived, { archived: false });
236 expect(woken.archivedAt).toBeNull();
237 expect(assignIssue(app, alice, woken, issue.value, bot).ok).toBe(true);
238 // The assignment after Wake queued the run the archive had refused.
239 const run = listRuns(app.db, woken.id, { issueId: issue.value.id }).find(
240 (r) => r.state === "queued",
241 );
242 expect(run).toBeDefined();
243 cancelRepoRun(app, alice, woken, run!.id);
244 });
189245 });
test/guests.test.ts +22
@@ -12,6 +12,7 @@ import { createApp, type App } from "../src/app.ts";
1212 import { changeCapacity } from "../src/actions/capacity.ts";
1313 import { proposePlan } from "../src/actions/plan.ts";
1414 import { redirectProposal } from "../src/actions/proposals.ts";
15 import { ARCHIVED_WHY, updateRepoSettings } from "../src/core/repos.ts";
1516 import { setCrewRole } from "../src/core/agents.ts";
1617 import { getProposal } from "../src/core/proposals.ts";
1718 import { listComments } from "../src/core/issues.ts";
@@ -288,6 +289,27 @@ describe("a guest cannot own, copy or run", () => {
288289 expect(getProposal(app.db, id)!.state).toBe("redirected");
289290 });
290291
292 test("redirecting a wave in an archived repository is refused with the archived line, and nothing is written", () => {
293 const proposed = proposePlan(app, alice, lab, [{ key: "a", title: "Prune", body: "", blockedBy: [] }], {
294 title: "Autumn",
295 });
296 if (!proposed.ok) throw new Error(JSON.stringify(proposed.error));
297 const id = proposed.value.id;
298 const direction = ensureDirectionIssue(app, alice, lab)!;
299 const comments = listComments(app.db, direction.id).length;
300 const archived = updateRepoSettings(app.db, lab, { archived: true });
301 setMember(app.db, bob.id, true, alice.id, new Date());
302 expect(redirectProposal(app, bob, id, "Roses first")).toEqual({
303 ok: false,
304 error: { code: "invalid", message: ARCHIVED_WHY },
305 });
306 expect(getProposal(app.db, id)!.state).toBe("pending");
307 expect(listComments(app.db, direction.id)).toHaveLength(comments);
308 updateRepoSettings(app.db, archived, { archived: false });
309 expect(redirectProposal(app, bob, id, "Roses first").ok).toBe(true);
310 setMember(app.db, bob.id, false, alice.id, new Date());
311 });
312
291313 test("the wake-up sweep starts no run on an issue a guest assigned, and a hundred of them do not hold back a member's", async () => {
292314 // As an assignment made before the guard (or by a member since demoted):
293315 // the agent set, the event kept.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs