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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2455-archive-part-2b-the-crew-starts-no-new-r → main 5 commits fork 6 threads · 6 unresolved
README.md +7 −2
| @@ -307,8 +307,13 @@ An admin puts a repository away from **Settings → Archive**, with | ||
| 307 | 307 | else gets 403), or in the terminal with `a` on the About card (`I`). The |
| 308 | 308 | repository page and the card then say when it was archived, and an admin gets |
| 309 | 309 | 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. | |
| 312 | 317 | |
| 313 | 318 | ## Health |
| 314 | 319 |
src/actions/follow.ts +3 −1
| @@ -9,7 +9,7 @@ import { NOT_A_MEMBER } from "../core/roles.ts"; | ||
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | 10 | import { DIRECTION_NOTE } from "../crew/direct.ts"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import { chainOfRepo, severChain } from "../spawn/chain.ts"; |
| @@ -107,6 +107,8 @@ export function convergeWords( | ||
| 107 | 107 | return { tone: "danger", message: DIRECTION_NOTE }; |
| 108 | 108 | case "not_member": |
| 109 | 109 | return { tone: "danger", message: `Not started: ${NOT_A_MEMBER}.` }; |
| 110 | case "archived": | |
| 111 | return { tone: "danger", message: ARCHIVED_WHY }; | |
| 110 | 112 | case "ceiling": |
| 111 | 113 | return { |
| 112 | 114 | tone: "danger", |
src/actions/issues.ts +3 −1
| @@ -295,7 +295,7 @@ export function assignIssue( | ||
| 295 | 295 | variantGroup?: string, |
| 296 | 296 | ): Result< |
| 297 | 297 | 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" | |
| 299 | 299 | > { |
| 300 | 300 | const writer = requireWriter(app, actor, repo); |
| 301 | 301 | if (!writer.ok) return writer; |
| @@ -316,6 +316,8 @@ export function assignIssue( | ||
| 316 | 316 | // ...and only a member's allowance pays for it. |
| 317 | 317 | if (assignee.kind === "agent" && !requireAbility(app, writer.value, "run_crew").ok) |
| 318 | 318 | 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"); | |
| 319 | 321 | } |
| 320 | 322 | const updated = setAssignee(app.db, issue.id, assignee?.id ?? null); |
| 321 | 323 | if (!updated) return ok(issue); |
src/actions/proposals.ts +2 −1
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | 13 | import { listCrewRoles } from "../core/agents.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import type { User } from "../core/users.ts"; |
| 17 | 17 | import { ensureDirectionIssue } from "../crew/autonomy.ts"; |
| 18 | 18 | import { queueAgentRun } from "../crew/dispatch.ts"; |
| @@ -94,6 +94,7 @@ export function redirectProposal( | ||
| 94 | 94 | const { proposal, repo } = found.value; |
| 95 | 95 | // The run it starts is a member's: refused before anything is written. |
| 96 | 96 | 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); | |
| 97 | 98 | const planner = listCrewRoles(app.db, repo.id).find((c) => c.role === "planner")?.agent; |
| 98 | 99 | if (planner === undefined) return fail("invalid", "no planner on this crew to hand it to"); |
| 99 | 100 | const direction = ensureDirectionIssue(app, found.value.actor, repo); |
src/actions/runs.ts +2 −1
| @@ -201,7 +201,7 @@ export function requeueRepoRun( | ||
| 201 | 201 | actor: User | undefined, |
| 202 | 202 | repo: Repo, |
| 203 | 203 | 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"> { | |
| 205 | 205 | const writer = requireWriter(app, actor, repo); |
| 206 | 206 | if (!writer.ok) return writer; |
| 207 | 207 | // A run costs money; like assigning, queueing one is a person's call. |
| @@ -209,6 +209,7 @@ export function requeueRepoRun( | ||
| 209 | 209 | const run = getRun(app.db, runId); |
| 210 | 210 | if (!run || run.repoId !== repo.id) return err("not_found"); |
| 211 | 211 | if (run.kind !== "agent") return err("not_agent"); |
| 212 | if (repo.archivedAt !== null) return err("archived"); | |
| 212 | 213 | const member = requireAbility(app, writer.value, "run_crew"); |
| 213 | 214 | if (!member.ok) return member; |
| 214 | 215 | if (run.state !== "cancelled" && run.state !== "failed") |
src/core/repos.ts +3
| @@ -1049,6 +1049,9 @@ export interface RepoSettings { | ||
| 1049 | 1049 | plansNeedNod?: boolean; |
| 1050 | 1050 | } |
| 1051 | 1051 | |
| 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 | ||
| 1052 | 1055 | /** Update the mutable repo fields, leaving unspecified ones untouched. */ |
| 1053 | 1056 | export function updateRepoSettings( |
| 1054 | 1057 | db: Database, |
src/crew/autonomy.ts +2
| @@ -1090,6 +1090,8 @@ export async function autonomyTick( | ||
| 1090 | 1090 | ): Promise<void> { |
| 1091 | 1091 | if (crewOffInPreview()) return; |
| 1092 | 1092 | 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; | |
| 1093 | 1095 | try { |
| 1094 | 1096 | void startSweep(app, repo, sweep); |
| 1095 | 1097 | // 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 = | ||
| 70 | 70 | | { kind: "forbidden" } |
| 71 | 71 | /** A guest: the run is a member's to start, refused before anything is written. */ |
| 72 | 72 | | { 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" }; | |
| 74 | 76 | |
| 75 | 77 | /** The fork's open converge issue, if the parent's updates are in hand. */ |
| 76 | 78 | export function convergeIssue( |
| @@ -149,6 +151,8 @@ export async function convergeFork( | ||
| 149 | 151 | return { kind: "forbidden" }; |
| 150 | 152 | if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok) |
| 151 | 153 | return { kind: "not_member" }; |
outdated
For a person who asked, this is silent. 'nothing' reads as 'already converged'. Return a distinct kind (for example |
||
| 154 | // An archived fork is put away: no issue, no run, and it says so. | |
| 155 | if (fork.archivedAt !== null) return { kind: "archived" }; | |
| 152 | 156 | const relation = await forkRelation(app, fork, parent); |
| 153 | 157 | if (relation === undefined || relation.state !== "diverged" || relation.clean) |
| 154 | 158 | return { kind: "nothing", relation }; |
src/crew/dispatch.ts +5
| @@ -290,6 +290,11 @@ export function queueAgentRun( | ||
| 290 | 290 | ): Run | undefined { |
| 291 | 291 | // A suspended or removed owner's repositories run nothing, whoever asks. |
| 292 | 292 | 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: |
||
| 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; | |
| 293 | 298 | // A guest's word starts no run (a comment is still a comment); the |
| 294 | 299 | // actions that start one by name refuse out loud before they get here. |
| 295 | 300 | 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"; | ||
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | 11 | import { getIssueById, listComments } from "../core/issues.ts"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { activeRunFor, listRuns, type Run } from "../core/runs.ts"; |
| 15 | 15 | import type { User } from "../core/users.ts"; |
| 16 | 16 | import { readConflictedFiles } from "../git/conflict-file.ts"; |
| @@ -27,7 +27,8 @@ export type ProposeError = | ||
| 27 | 27 | | "no_agent" |
| 28 | 28 | | "busy" |
| 29 | 29 | | "not_member" |
| 30 | | "not_queued"; | |
| 30 | | "not_queued" | |
| 31 | | "archived"; | |
| 31 | 32 | |
| 32 | 33 | /** What the brief carries at most: a hand's worth, never a whole history. */ |
| 33 | 34 | const MAX_COMMITS = 20; |
| @@ -49,6 +50,7 @@ export async function proposeResolution( | ||
| 49 | 50 | ): Promise<Result<Run, ProposeError>> { |
| 50 | 51 | if (!canWrite(app.db, repo, actor)) return err("forbidden"); |
| 51 | 52 | if (pull.state !== "open") return err("not_open"); |
| 53 | if (repo.archivedAt !== null) return err("archived"); | |
| 52 | 54 | if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok) return err("not_member"); |
| 53 | 55 | const issue = getIssueById(app.db, pull.id); |
| 54 | 56 | const agent = issue === undefined ? undefined : agentFor(app, issue); |
| @@ -218,6 +220,7 @@ export const PROPOSE_WHY: Record<ProposeError, string> = { | ||
| 218 | 220 | no_agent: "No agent holds this pull, so the crew has no hand to ask.", |
| 219 | 221 | busy: "The crew is already working on this pull.", |
| 220 | 222 | not_member: "Starting a run is for members: the owner has not made this account a member.", |
| 223 | archived: ARCHIVED_WHY, | |
| 221 | 224 | not_queued: "The crew could not take it on right now (its ceiling or breaker); try again later.", |
| 222 | 225 | }; |
| 223 | 226 |
src/fleet/verbs.ts +2
| @@ -33,6 +33,7 @@ import type { FleetValue } from "../core/fleet_ops.ts"; | ||
| 33 | 33 | import { findPillar, isStructural, type Pillars } from "../core/pillars.ts"; |
| 34 | 34 | import { getIssue, type Issue } from "../core/issues.ts"; |
| 35 | 35 | import { |
| 36 | ARCHIVED_WHY, | |
| 36 | 37 | canRead, |
| 37 | 38 | isAutonomy, |
| 38 | 39 | type Repo, |
| @@ -122,6 +123,7 @@ const WORDS: Record<string, string> = { | ||
| 122 | 123 | not_agent: "only an agent run is queued again", |
| 123 | 124 | not_finished: "only a cancelled or failed run is queued again", |
| 124 | 125 | no_task: "its issue is closed or its agent is gone", |
| 126 | archived: ARCHIVED_WHY, | |
| 125 | 127 | not_queued: "the crew would not take it now (already queued, parked, or at the ceiling)", |
| 126 | 128 | unknown_branch: "unknown branch", |
| 127 | 129 | }; |
src/http/api.ts +1
| @@ -2310,6 +2310,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2310 | 2310 | if (parent === undefined) |
| 2311 | 2311 | return apiError("not_a_fork", 404, "this repository follows nothing"); |
| 2312 | 2312 | const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user); |
| 2313 | if (outcome.kind === "archived") return respondAccess("archived", 409); | |
| 2313 | 2314 | const status = |
| 2314 | 2315 | outcome.kind === "queued" |
| 2315 | 2316 | ? 202 |
src/http/mcp.ts +2
| @@ -104,6 +104,7 @@ import { pillarView } from "../core/pillars.ts"; | ||
| 104 | 104 | import { askToLiveFor } from "../web/ask_to_live.ts"; |
| 105 | 105 | import { getAppByRepo } from "../core/apps.ts"; |
| 106 | 106 | import { |
| 107 | ARCHIVED_WHY, | |
| 107 | 108 | canRead, |
| 108 | 109 | canWrite, |
| 109 | 110 | forkParentFor, |
| @@ -1523,6 +1524,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1523 | 1524 | const outcome = await convergeFork(ctx.app, repo, parent, user); |
| 1524 | 1525 | if (outcome.kind === "forbidden") return bad("forbidden", DIRECTION_NOTE); |
| 1525 | 1526 | if (outcome.kind === "not_member") return bad("not_member", NOT_A_MEMBER); |
| 1527 | if (outcome.kind === "archived") return bad("archived", ARCHIVED_WHY); | |
| 1526 | 1528 | return good({ |
| 1527 | 1529 | kind: outcome.kind, |
| 1528 | 1530 | issue: "issue" in outcome ? outcome.issue.number : null, |
src/http/respond.ts +2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { bodyCapFor, readBounded } from "./limits.ts"; |
| 8 | 8 | import { scopeAllows, type TokenScope } from "../core/tokens.ts"; |
| 9 | import { ARCHIVED_WHY } from "../core/repos.ts"; | |
| 9 | 10 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 10 | 11 | import { NEW_ACCOUNT_GUARD_MESSAGE } from "../core/capacity.ts"; |
| 11 | 12 | import type { User } from "../core/users.ts"; |
| @@ -155,6 +156,7 @@ export function respondAccess(error: string, status: number): Response { | ||
| 155 | 156 | // clients see no difference between the trigger and the setting. |
| 156 | 157 | if (error === "refused") |
| 157 | 158 | return json({ error, message: NEW_ACCOUNT_GUARD_MESSAGE }, 403); |
| 159 | if (error === "archived") return json({ error, message: ARCHIVED_WHY }, 409); | |
| 158 | 160 | if (error === "not_member") |
| 159 | 161 | return json({ error, message: NOT_A_MEMBER }, 403); |
| 160 | 162 | if (error === "unauthorized") return unauthorized(); |
src/tui/screens/common.ts +2 −1
| @@ -11,7 +11,7 @@ import { NOT_A_MEMBER } from "../../core/roles.ts"; | ||
| 11 | 11 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 12 | import { binding, type Binding } from "../app/keys.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts"; |
| 16 | 16 | import type { Session } from "../session.ts"; |
| 17 | 17 | import { G, Screen as Surface, type Rect } from "../term/screen.ts"; |
| @@ -815,6 +815,7 @@ export function slug(path: string): string { | ||
| 815 | 815 | // ---- errors and time ---- |
| 816 | 816 | |
| 817 | 817 | const MESSAGES: Record<string, string> = { |
| 818 | archived: ARCHIVED_WHY, | |
| 818 | 819 | not_member: NOT_A_MEMBER, |
| 819 | 820 | not_found: "not found", |
| 820 | 821 | signup_closed: SIGNUP_CLOSED_MESSAGE, |
src/web/pages/thread.ts +2 −1
| @@ -38,7 +38,7 @@ import { | ||
| 38 | 38 | MAX_BODY, |
| 39 | 39 | MAX_TITLE, |
| 40 | 40 | } 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"; | |
| 42 | 42 | import { inlineIcons, type IconName as Glyph } from "../icons.ts"; |
| 43 | 43 | import { |
| 44 | 44 | getUserById, |
| @@ -725,6 +725,7 @@ export async function assigneeSubmit( | ||
| 725 | 725 | assignee_cannot_read: `${name} cannot see this repository, so cannot be assigned to it.`, |
| 726 | 726 | agent_not_on_crew: `${name} is not on this repository's crew. Add it under Settings → Crew first.`, |
| 727 | 727 | forbidden: "You need write access to assign.", |
| 728 | archived: ARCHIVED_WHY, | |
| 728 | 729 | }; |
| 729 | 730 | return flashTo(ctx, dest, { |
| 730 | 731 | tone: "danger", |
test/crew.test.ts +58 −2
| @@ -11,6 +11,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 11 | 11 | import { existsSync, readFileSync } from "node:fs"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | 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"; | |
| 14 | 22 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 15 | 23 | import { sandboxAvailable } from "../src/crew/sandbox.ts"; |
| 16 | 24 | import type { CrewHandle } from "../src/crew/dispatch.ts"; |
| @@ -20,8 +28,8 @@ import { listPulls } from "../src/core/pulls.ts"; | ||
| 20 | 28 | import { resolveOid } from "../src/git/merge.ts"; |
| 21 | 29 | import { listPaths } from "../src/git/read.ts"; |
| 22 | 30 | 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"; | |
| 25 | 33 | import type { User } from "../src/core/users.ts"; |
| 26 | 34 | import type { Servers } from "../src/http/server.ts"; |
| 27 | 35 | import { readRunLog } from "../src/runs/log.ts"; |
| @@ -186,4 +194,52 @@ describe("the crew, with a stand-in Claude Code", () => { | ||
| 186 | 194 | ), |
| 187 | 195 | ).toBe(true); |
| 188 | 196 | }, 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 |
||
| 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 | }); | |
| 189 | 245 | }); |
test/guests.test.ts +22
| @@ -12,6 +12,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 12 | import { changeCapacity } from "../src/actions/capacity.ts"; |
| 13 | 13 | import { proposePlan } from "../src/actions/plan.ts"; |
| 14 | 14 | import { redirectProposal } from "../src/actions/proposals.ts"; |
| 15 | import { ARCHIVED_WHY, updateRepoSettings } from "../src/core/repos.ts"; | |
| 15 | 16 | import { setCrewRole } from "../src/core/agents.ts"; |
| 16 | 17 | import { getProposal } from "../src/core/proposals.ts"; |
| 17 | 18 | import { listComments } from "../src/core/issues.ts"; |
| @@ -288,6 +289,27 @@ describe("a guest cannot own, copy or run", () => { | ||
| 288 | 289 | expect(getProposal(app.db, id)!.state).toBe("redirected"); |
| 289 | 290 | }); |
| 290 | 291 | |
| 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 | ||
| 291 | 313 | 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 () => { |
| 292 | 314 | // As an assignment made before the guard (or by a member since demoted): |
| 293 | 315 | // the agent set, the event kept. |
No test covers this skip. Add one: an archived autonomous repository whose tick queues nothing.