@saadnvd1/agent-os 0.3.671 → 0.3.672
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/projects/[id]/route.ts +26 -0
- package/components/Projects/ProjectSettingsDialog.tsx +7 -0
- package/components/Projects/ProjectTierField.tsx +39 -0
- package/components/Workspaces/ProjectTiersForm.tsx +33 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/projects/queries.ts +4 -0
- package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
- package/lib/db/types.ts +4 -0
- package/lib/orchestrator/armed.test-helpers.ts +4 -0
- package/lib/orchestrator/armed.ts +14 -1
- package/lib/orchestrator/brief.ts +9 -2
- package/lib/orchestrator/fast-lane-state.ts +69 -0
- package/lib/orchestrator/fast-lane.ts +96 -0
- package/lib/orchestrator/gates.ts +8 -2
- package/lib/orchestrator/project-tier-tool.ts +33 -0
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +6 -1
- package/lib/orchestrator/signoff.test-helpers.ts +3 -0
- package/lib/orchestrator/signoff.ts +36 -19
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +8 -1
- package/lib/orchestrator/tools.ts +2 -0
- package/lib/orchestrator/watcher.ts +5 -0
- package/lib/project-tier/getProjectTier.ts +18 -0
- package/lib/project-tier/index.ts +3 -0
- package/lib/project-tier/setProjectTier.ts +14 -0
- package/lib/project-tier/types.ts +3 -0
- package/lib/start-options.ts +8 -1
- package/lib/tasks/finish.ts +2 -1
- package/lib/tasks/index.ts +10 -5
- package/lib/tasks/state.ts +7 -1
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { setProjectWorkspace } from "@/lib/workspaces";
|
|
4
|
+
import { PROJECT_TIERS, setProjectTier } from "@/lib/project-tier";
|
|
3
5
|
import {
|
|
4
6
|
getProjectWithDevServers,
|
|
5
7
|
updateProject,
|
|
@@ -7,6 +9,8 @@ import {
|
|
|
7
9
|
toggleProjectExpanded,
|
|
8
10
|
} from "@/lib/projects";
|
|
9
11
|
|
|
12
|
+
const tierSchema = z.enum(PROJECT_TIERS);
|
|
13
|
+
|
|
10
14
|
interface RouteParams {
|
|
11
15
|
params: Promise<{ id: string }>;
|
|
12
16
|
}
|
|
@@ -46,6 +50,17 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
|
46
50
|
workspaceId,
|
|
47
51
|
} = body;
|
|
48
52
|
|
|
53
|
+
let tier: z.infer<typeof tierSchema> | undefined;
|
|
54
|
+
if (body.tier !== undefined) {
|
|
55
|
+
const parsed = tierSchema.safeParse(body.tier);
|
|
56
|
+
if (!parsed.success)
|
|
57
|
+
return NextResponse.json(
|
|
58
|
+
{ error: "tier must be production or fast" },
|
|
59
|
+
{ status: 400 }
|
|
60
|
+
);
|
|
61
|
+
tier = parsed.data;
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
// Handle expanded toggle separately
|
|
50
65
|
if (typeof expanded === "boolean") {
|
|
51
66
|
toggleProjectExpanded(id, expanded);
|
|
@@ -56,6 +71,17 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
|
56
71
|
setProjectWorkspace(id, workspaceId);
|
|
57
72
|
}
|
|
58
73
|
|
|
74
|
+
if (tier) {
|
|
75
|
+
try {
|
|
76
|
+
setProjectTier(id, tier);
|
|
77
|
+
} catch {
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{ error: "Project not found or cannot be modified" },
|
|
80
|
+
{ status: 404 }
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
// Update other fields if provided
|
|
60
86
|
if (
|
|
61
87
|
name ||
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
import { Button } from "@/components/ui/button";
|
|
13
13
|
import { Input } from "@/components/ui/input";
|
|
14
14
|
import { Textarea } from "@/components/ui/textarea";
|
|
15
|
+
import { ProjectTierField } from "./ProjectTierField";
|
|
16
|
+
import type { ProjectTier } from "@/lib/project-tier/types";
|
|
15
17
|
import {
|
|
16
18
|
Select,
|
|
17
19
|
SelectContent,
|
|
@@ -95,6 +97,7 @@ export function ProjectSettingsDialog({
|
|
|
95
97
|
getDefaultModelForAgent("claude")
|
|
96
98
|
);
|
|
97
99
|
const [initialPrompt, setInitialPrompt] = useState("");
|
|
100
|
+
const [tier, setTier] = useState<ProjectTier>("fast");
|
|
98
101
|
const [devServers, setDevServers] = useState<DevServerConfig[]>([]);
|
|
99
102
|
const [repositories, setRepositories] = useState<RepositoryConfig[]>([]);
|
|
100
103
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -118,6 +121,7 @@ export function ProjectSettingsDialog({
|
|
|
118
121
|
setName(project.name);
|
|
119
122
|
setWorkingDirectory(project.working_directory);
|
|
120
123
|
setAgentType(project.agent_type);
|
|
124
|
+
setTier(project.tier);
|
|
121
125
|
setDefaultModel(
|
|
122
126
|
isSupportedModelForAgent(project.agent_type, project.default_model)
|
|
123
127
|
? project.default_model
|
|
@@ -336,6 +340,7 @@ export function ProjectSettingsDialog({
|
|
|
336
340
|
agentType,
|
|
337
341
|
defaultModel,
|
|
338
342
|
initialPrompt: initialPrompt.trim() || null,
|
|
343
|
+
tier,
|
|
339
344
|
});
|
|
340
345
|
|
|
341
346
|
// Handle dev server changes
|
|
@@ -526,6 +531,8 @@ export function ProjectSettingsDialog({
|
|
|
526
531
|
</Select>
|
|
527
532
|
</div>
|
|
528
533
|
|
|
534
|
+
<ProjectTierField value={tier} onChange={setTier} />
|
|
535
|
+
|
|
529
536
|
{/* Initial Prompt */}
|
|
530
537
|
<div className="space-y-2">
|
|
531
538
|
<label className="text-sm font-medium">Initial Prompt</label>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from "@/components/ui/select";
|
|
10
|
+
import type { ProjectTier } from "@/lib/project-tier/types";
|
|
11
|
+
|
|
12
|
+
const DETAIL: Record<ProjectTier, string> = {
|
|
13
|
+
production: "CI, the independent review and scope must pass before a merge.",
|
|
14
|
+
fast: "A task's PR merges as soon as it opens: no CI wait, no review.",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function ProjectTierField({
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
}: {
|
|
21
|
+
value: ProjectTier;
|
|
22
|
+
onChange: (tier: ProjectTier) => void;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-2">
|
|
26
|
+
<label className="text-sm font-medium">Tier</label>
|
|
27
|
+
<Select value={value} onValueChange={(v) => onChange(v as ProjectTier)}>
|
|
28
|
+
<SelectTrigger>
|
|
29
|
+
<SelectValue />
|
|
30
|
+
</SelectTrigger>
|
|
31
|
+
<SelectContent>
|
|
32
|
+
<SelectItem value="fast">Fast</SelectItem>
|
|
33
|
+
<SelectItem value="production">Production</SelectItem>
|
|
34
|
+
</SelectContent>
|
|
35
|
+
</Select>
|
|
36
|
+
<p className="text-muted-foreground text-xs">{DETAIL[value]}</p>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SettingToggle } from "@/components/Settings/SettingToggle";
|
|
4
|
+
import { useProjectsQuery, useUpdateProject } from "@/data/projects";
|
|
5
|
+
|
|
6
|
+
// A workspace's projects, each switched between production (every merge gate)
|
|
7
|
+
// and fast (a task's PR merges as soon as it opens).
|
|
8
|
+
export function ProjectTiersForm({ workspaceId }: { workspaceId: string }) {
|
|
9
|
+
const { data: projects } = useProjectsQuery();
|
|
10
|
+
const update = useUpdateProject();
|
|
11
|
+
const mine = (projects ?? []).filter(
|
|
12
|
+
(p) => p.workspace_id === workspaceId && !p.is_uncategorized
|
|
13
|
+
);
|
|
14
|
+
if (!mine.length) return null;
|
|
15
|
+
return (
|
|
16
|
+
<div className="space-y-2">
|
|
17
|
+
{mine.map((p) => (
|
|
18
|
+
<SettingToggle
|
|
19
|
+
key={p.id}
|
|
20
|
+
title={`${p.name}: production`}
|
|
21
|
+
detail="On: CI, the independent review and scope gate its merges. Off (fast): a task's PR merges as soon as it opens."
|
|
22
|
+
checked={p.tier === "production"}
|
|
23
|
+
onChange={(on) =>
|
|
24
|
+
update.mutate({
|
|
25
|
+
projectId: p.id,
|
|
26
|
+
tier: on ? "production" : "fast",
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
/>
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -7,6 +7,7 @@ import { RulesForm } from "./RulesForm";
|
|
|
7
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
8
8
|
import { McpServersForm } from "./McpServersForm";
|
|
9
9
|
import { HqFolderForm } from "./HqFolderForm";
|
|
10
|
+
import { ProjectTiersForm } from "./ProjectTiersForm";
|
|
10
11
|
import { StallForm } from "./StallForm";
|
|
11
12
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
12
13
|
import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
|
|
@@ -49,6 +50,7 @@ export function WorkspaceSettings() {
|
|
|
49
50
|
<WorkspaceModelsForm workspaceId={w.id} />
|
|
50
51
|
<McpServersForm workspace={w} />
|
|
51
52
|
<HqFolderForm workspaceId={w.id} />
|
|
53
|
+
<ProjectTiersForm workspaceId={w.id} />
|
|
52
54
|
<SettingToggle
|
|
53
55
|
title="Wake tasks on CI failures and reviews"
|
|
54
56
|
detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
|
package/data/projects/queries.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ProjectWithRepositories,
|
|
4
4
|
DetectedDevServer,
|
|
5
5
|
} from "@/lib/projects";
|
|
6
|
+
import type { ProjectTier } from "@/lib/db/types";
|
|
6
7
|
import { projectKeys } from "./keys";
|
|
7
8
|
import { sessionKeys } from "../sessions/keys";
|
|
8
9
|
|
|
@@ -131,6 +132,7 @@ export function useUpdateProject() {
|
|
|
131
132
|
agentType,
|
|
132
133
|
defaultModel,
|
|
133
134
|
initialPrompt,
|
|
135
|
+
tier,
|
|
134
136
|
}: {
|
|
135
137
|
projectId: string;
|
|
136
138
|
name?: string;
|
|
@@ -138,6 +140,7 @@ export function useUpdateProject() {
|
|
|
138
140
|
agentType?: string;
|
|
139
141
|
defaultModel?: string;
|
|
140
142
|
initialPrompt?: string | null;
|
|
143
|
+
tier?: ProjectTier;
|
|
141
144
|
}) => {
|
|
142
145
|
const res = await fetch(`/api/projects/${projectId}`, {
|
|
143
146
|
method: "PATCH",
|
|
@@ -148,6 +151,7 @@ export function useUpdateProject() {
|
|
|
148
151
|
agentType,
|
|
149
152
|
defaultModel,
|
|
150
153
|
initialPrompt,
|
|
154
|
+
tier,
|
|
151
155
|
}),
|
|
152
156
|
});
|
|
153
157
|
if (!res.ok) throw new Error("Failed to update project");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261018T2130_project_tier",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// A project's tier: "production" tasks pass every merge gate, "fast"
|
|
7
|
+
// ones merge as soon as their PR opens (orchestrator/fast-lane.ts). The
|
|
8
|
+
// LumifyHub and Workstak projects start production, in the same step as
|
|
9
|
+
// the column, so running this again never overwrites a tier Saad changed.
|
|
10
|
+
const cols = db.prepare(`PRAGMA table_info(projects)`).all() as {
|
|
11
|
+
name: string;
|
|
12
|
+
}[];
|
|
13
|
+
if (!cols.some((c) => c.name === "tier")) {
|
|
14
|
+
db.exec(
|
|
15
|
+
`ALTER TABLE projects ADD COLUMN tier TEXT NOT NULL DEFAULT 'fast'`
|
|
16
|
+
);
|
|
17
|
+
db.exec(
|
|
18
|
+
`UPDATE projects SET tier = 'production'
|
|
19
|
+
WHERE lower(name) LIKE 'lumifyhub%' OR lower(name) LIKE 'workstak%'
|
|
20
|
+
OR workspace_id IN (
|
|
21
|
+
SELECT id FROM workspaces WHERE lower(name) IN ('lumifyhub', 'workstak')
|
|
22
|
+
)`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
// What the fast lane has done to a task: the PR it armed (so a disarm
|
|
26
|
+
// sticks) and the merge whose CI it still watches.
|
|
27
|
+
db.exec(`CREATE TABLE IF NOT EXISTS fast_lane_tasks (
|
|
28
|
+
session_id TEXT PRIMARY KEY,
|
|
29
|
+
workspace_id TEXT NOT NULL,
|
|
30
|
+
auto_armed_pr INTEGER,
|
|
31
|
+
merged_sha TEXT,
|
|
32
|
+
merged_pr INTEGER,
|
|
33
|
+
merged_at TEXT,
|
|
34
|
+
ci_checked_at TEXT,
|
|
35
|
+
ci_done_at TEXT
|
|
36
|
+
)`);
|
|
37
|
+
},
|
|
38
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -175,6 +175,9 @@ export interface Group {
|
|
|
175
175
|
created_at: string;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// production: every merge gate applies. fast: a task's PR merges when it opens.
|
|
179
|
+
export type ProjectTier = "production" | "fast";
|
|
180
|
+
|
|
178
181
|
export interface Project {
|
|
179
182
|
id: string;
|
|
180
183
|
name: string;
|
|
@@ -190,6 +193,7 @@ export interface Project {
|
|
|
190
193
|
lh_board_id: string | null;
|
|
191
194
|
lh_board_name: string | null;
|
|
192
195
|
lh_board_page_id: string | null;
|
|
196
|
+
tier: ProjectTier;
|
|
193
197
|
// What its tasks need from a machine (JSON array), and where that came from.
|
|
194
198
|
machine_needs?: string | null;
|
|
195
199
|
machine_needs_source?: string | null;
|
|
@@ -125,6 +125,10 @@ export function setup() {
|
|
|
125
125
|
workingDirectory: repo,
|
|
126
126
|
});
|
|
127
127
|
setProjectWorkspace(project.id, workspace.id);
|
|
128
|
+
// These tests judge today's gates; fast-lane.test.ts flips it.
|
|
129
|
+
db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
|
|
130
|
+
project.id
|
|
131
|
+
);
|
|
128
132
|
ensureOrchestrator(workspace.id);
|
|
129
133
|
const task = seedSession({
|
|
130
134
|
projectId: project.id,
|
|
@@ -26,6 +26,7 @@ import { getOrchestrator } from "./home";
|
|
|
26
26
|
import { isPaused } from "./pause";
|
|
27
27
|
import { review } from "./review";
|
|
28
28
|
import { judge, mergeJudged } from "./signoff";
|
|
29
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
29
30
|
import { workspaceTask } from "./targets";
|
|
30
31
|
import { markStatusDirty } from "../status/dirty";
|
|
31
32
|
import { setPr } from "../db/session-parts";
|
|
@@ -88,9 +89,10 @@ export const anyArmed = (workspaceId: string) =>
|
|
|
88
89
|
armedTasks(workspaceId).length > 0;
|
|
89
90
|
|
|
90
91
|
// The orchestrator's `arm` and `disarm`.
|
|
91
|
-
export function arm(workspaceId: string, ref: string): string {
|
|
92
|
+
export async function arm(workspaceId: string, ref: string): Promise<string> {
|
|
92
93
|
const task = workspaceTask(workspaceId, ref);
|
|
93
94
|
armTask(task);
|
|
95
|
+
if (isFast(task)) return armedNow(workspaceId, task);
|
|
94
96
|
return `Armed ${task.name}: AgentOS merges its PR the moment every sign_off gate passes at its head, and tells you with one event. A gate failure also comes as one event; it stays armed for the fix, unless the failure goes to Saad. No need to call sign_off.`;
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -124,6 +126,17 @@ export function armTask(task: Session): void {
|
|
|
124
126
|
setArmed(task.id, true);
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
// A fast task merges the moment it's armed, if its PR is open.
|
|
130
|
+
async function armedNow(workspaceId: string, task: Session): Promise<string> {
|
|
131
|
+
const verdict = await judge(workspaceId, task, false, { count: false });
|
|
132
|
+
if (!verdict.ok)
|
|
133
|
+
return `Armed ${task.name} (fast project); not merged yet.\n${verdict.text}\nIt stays armed and merges once that is fixed.`;
|
|
134
|
+
await merge(workspaceId, task, verdict, defaults);
|
|
135
|
+
return stillArmed(workspaceId, task.id)
|
|
136
|
+
? `Armed ${task.name} (fast project); the merge was refused, see the event. It stays armed.`
|
|
137
|
+
: `Merged ${task.name} (fast project): PR #${verdict.pr} at ${short(verdict.sha)}, no CI wait or review.`;
|
|
138
|
+
}
|
|
139
|
+
|
|
127
140
|
export function disarm(workspaceId: string, ref: string): string {
|
|
128
141
|
const task = workspaceTask(workspaceId, ref);
|
|
129
142
|
setArmed(task.id, false);
|
|
@@ -28,6 +28,7 @@ export interface BriefProject {
|
|
|
28
28
|
path: string;
|
|
29
29
|
board: string | null;
|
|
30
30
|
defaultBranch: string;
|
|
31
|
+
tier?: "production" | "fast";
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
// Read when the brief is built, so a switch flipped in Settings reaches the
|
|
@@ -121,7 +122,7 @@ Reading:
|
|
|
121
122
|
Acting:
|
|
122
123
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
123
124
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
124
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
125
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, now?, now_reason?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. now true (with now_reason, not with after) starts it at once past the limit instead; it's logged for Saad, so use it only when it can't wait. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task in a production project (see "Triage every job"); a fast project skips the plan unless you pass plan true. Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
125
126
|
- \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
|
|
126
127
|
- \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
|
|
127
128
|
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
@@ -145,6 +146,7 @@ Acting:
|
|
|
145
146
|
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
|
|
146
147
|
- \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
|
|
147
148
|
- \`${TOOL_NAMES.resurrect}\` (session, reason): bring a dropped, done or archived session back with its conversation and worktree, in the state it had (a plan-only task waits for approval again). Use it when a drop or done was a mistake, or when Saad asks; refused for a merged PR, a missing conversation or a removed project. A restored task counts against the running limit and the brakes like a start.
|
|
149
|
+
- \`${TOOL_NAMES.project_tier}\` (project?, tier?): read the workspace's projects with their tiers, or one's; with tier (production or fast) set it. Only when Saad says to.
|
|
148
150
|
- \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
|
|
149
151
|
- \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
|
|
150
152
|
- \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
|
|
@@ -166,11 +168,15 @@ Act through these tools, not the shell: they're scoped to this workspace and bra
|
|
|
166
168
|
|
|
167
169
|
## Untrusted text
|
|
168
170
|
|
|
171
|
+
### Tiers
|
|
172
|
+
|
|
173
|
+
Every project has a tier (shown on its line above; \`project_tier\` reads or sets it, and you set it only when Saad says to). production: everything above applies. fast (the default, for Saad's internal tools and AgentOS itself): a task's PR merges as soon as it opens or is pushed, so rigorous testing and CI don't hold it. AgentOS arms a fast task by itself when its PR opens, and \`arm\` on one merges it right away. Only these still apply: base, stack order, no AI attribution in the PR body, Saad's hold (BLOCKED, an open ask, a draft), the rules on the diff (no secrets, nothing outside the repository, not only lockfiles), merge approvals for sensitive files, and the hard lines. There is no ci or review gate, no card scope check and no loop guard, \`review\` doesn't start a reviewer, and a fast task skips the plan step unless you pass plan true. If CI fails after a fast merge you get one event; fix it forward with a task.
|
|
174
|
+
|
|
169
175
|
${UNTRUSTED_RULE}`;
|
|
170
176
|
|
|
171
177
|
function projectLine(p: BriefProject): string {
|
|
172
178
|
const board = p.board ? `, LumifyHub board "${p.board}"` : "";
|
|
173
|
-
return `- ${p.name}: \`${p.path}\` (default branch ${p.defaultBranch}${board})`;
|
|
179
|
+
return `- ${p.name}: \`${p.path}\` (default branch ${p.defaultBranch}${board}, tier ${p.tier ?? "production"})`;
|
|
174
180
|
}
|
|
175
181
|
|
|
176
182
|
export function orchestratorBrief(input: {
|
|
@@ -217,6 +223,7 @@ export async function loadOrchestratorBrief(session: Session): Promise<string> {
|
|
|
217
223
|
defaultBranch: await getDefaultBranch(p.working_directory).catch(
|
|
218
224
|
() => "main"
|
|
219
225
|
),
|
|
226
|
+
tier: p.tier,
|
|
220
227
|
}))
|
|
221
228
|
);
|
|
222
229
|
return orchestratorBrief({
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
|
|
3
|
+
const now = () => new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
4
|
+
|
|
5
|
+
export interface FastLaneRow {
|
|
6
|
+
session_id: string;
|
|
7
|
+
workspace_id: string;
|
|
8
|
+
auto_armed_pr: number | null;
|
|
9
|
+
merged_sha: string | null;
|
|
10
|
+
merged_pr: number | null;
|
|
11
|
+
merged_at: string | null;
|
|
12
|
+
ci_checked_at: string | null;
|
|
13
|
+
ci_done_at: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fastLaneRow(sessionId: string): FastLaneRow | undefined {
|
|
17
|
+
return db
|
|
18
|
+
.prepare(`SELECT * FROM fast_lane_tasks WHERE session_id = ?`)
|
|
19
|
+
.get(sessionId) as FastLaneRow | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function noteAutoArmed(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
workspaceId: string,
|
|
25
|
+
pr: number
|
|
26
|
+
): void {
|
|
27
|
+
db.prepare(
|
|
28
|
+
`INSERT INTO fast_lane_tasks (session_id, workspace_id, auto_armed_pr)
|
|
29
|
+
VALUES (?, ?, ?)
|
|
30
|
+
ON CONFLICT(session_id) DO UPDATE SET auto_armed_pr = excluded.auto_armed_pr`
|
|
31
|
+
).run(sessionId, workspaceId, pr);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A fast merge: the PR whose CI is watched for a while afterwards.
|
|
35
|
+
export function recordFastMerge(
|
|
36
|
+
sessionId: string,
|
|
37
|
+
workspaceId: string,
|
|
38
|
+
pr: number,
|
|
39
|
+
sha: string
|
|
40
|
+
): void {
|
|
41
|
+
db.prepare(
|
|
42
|
+
`INSERT INTO fast_lane_tasks (session_id, workspace_id, merged_sha, merged_pr, merged_at)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?)
|
|
44
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
45
|
+
merged_sha = excluded.merged_sha, merged_pr = excluded.merged_pr,
|
|
46
|
+
merged_at = excluded.merged_at, ci_checked_at = NULL, ci_done_at = NULL`
|
|
47
|
+
).run(sessionId, workspaceId, sha, pr, now());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function markCiChecked(sessionId: string, done: boolean): void {
|
|
51
|
+
db.prepare(
|
|
52
|
+
`UPDATE fast_lane_tasks SET ci_checked_at = ?, ci_done_at = CASE WHEN ? THEN ? ELSE ci_done_at END
|
|
53
|
+
WHERE session_id = ?`
|
|
54
|
+
).run(now(), done ? 1 : 0, now(), sessionId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Merges still inside their CI watch.
|
|
58
|
+
export function watchedMerges(
|
|
59
|
+
workspaceId: string,
|
|
60
|
+
sinceHours: number
|
|
61
|
+
): FastLaneRow[] {
|
|
62
|
+
return db
|
|
63
|
+
.prepare(
|
|
64
|
+
`SELECT * FROM fast_lane_tasks
|
|
65
|
+
WHERE workspace_id = ? AND merged_at IS NOT NULL AND ci_done_at IS NULL
|
|
66
|
+
AND merged_at > datetime('now', ?)`
|
|
67
|
+
)
|
|
68
|
+
.all(workspaceId, `-${sinceHours} hours`) as FastLaneRow[];
|
|
69
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fast lane: a project whose tier is "fast" (project-tier) has no CI
|
|
3
|
+
* wait, independent review or scope check on a task's PR. Its gates are
|
|
4
|
+
* trimmed in gates.ts and signoff.ts; this arms each fast task once when its
|
|
5
|
+
* PR opens, so armed.ts merges it on the same pass, and watches the CI of
|
|
6
|
+
* what it merged for a while, telling the orchestrator once if that fails.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db, type Session } from "../db";
|
|
10
|
+
import { run } from "../tasks/gh";
|
|
11
|
+
import { checksVerdict, failingCheck, type RollupEntry } from "../tasks/state";
|
|
12
|
+
import { armTask } from "./armed";
|
|
13
|
+
import { queueEventOnce } from "./events";
|
|
14
|
+
import type { SessionFacts } from "./facts";
|
|
15
|
+
import { isPaused } from "./pause";
|
|
16
|
+
import { repoOf } from "./repo";
|
|
17
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
18
|
+
import {
|
|
19
|
+
fastLaneRow,
|
|
20
|
+
markCiChecked,
|
|
21
|
+
noteAutoArmed,
|
|
22
|
+
watchedMerges,
|
|
23
|
+
} from "./fast-lane-state";
|
|
24
|
+
|
|
25
|
+
const CI_WATCH_HOURS = 3;
|
|
26
|
+
const CI_RECHECK_MS = 2 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
export async function autoArmFast(
|
|
29
|
+
workspaceId: string,
|
|
30
|
+
facts: SessionFacts[]
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (isPaused(workspaceId)) return;
|
|
33
|
+
for (const f of facts) {
|
|
34
|
+
const pr = f.task?.pr;
|
|
35
|
+
if (!pr || pr.state !== "OPEN") continue;
|
|
36
|
+
const task = db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT * FROM session_rows WHERE id = ? AND task_status = 'running'
|
|
39
|
+
AND merge_armed_at IS NULL`
|
|
40
|
+
)
|
|
41
|
+
.get(f.id) as Session | undefined;
|
|
42
|
+
if (!task || !isFast(task)) continue;
|
|
43
|
+
if (fastLaneRow(task.id)?.auto_armed_pr === pr.number) continue;
|
|
44
|
+
try {
|
|
45
|
+
armTask(task);
|
|
46
|
+
noteAutoArmed(task.id, workspaceId, pr.number);
|
|
47
|
+
} catch {
|
|
48
|
+
// With Saad, or no orchestrator to tell: retried next pass.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function ghRollup(task: Session, pr: number): Promise<RollupEntry[]> {
|
|
54
|
+
const out = await run(
|
|
55
|
+
"gh",
|
|
56
|
+
["pr", "view", String(pr), "--json", "statusCheckRollup"],
|
|
57
|
+
repoOf(task),
|
|
58
|
+
15000
|
|
59
|
+
);
|
|
60
|
+
return (JSON.parse(out).statusCheckRollup ?? []) as RollupEntry[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function followFastMerges(
|
|
64
|
+
workspaceId: string,
|
|
65
|
+
rollupOf: typeof ghRollup = ghRollup
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
for (const row of watchedMerges(workspaceId, CI_WATCH_HOURS)) {
|
|
69
|
+
const checked = row.ci_checked_at
|
|
70
|
+
? Date.parse(row.ci_checked_at.replace(" ", "T") + "Z")
|
|
71
|
+
: 0;
|
|
72
|
+
if (now - checked < CI_RECHECK_MS) continue;
|
|
73
|
+
const task = db
|
|
74
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
75
|
+
.get(row.session_id) as Session | undefined;
|
|
76
|
+
if (!task || !row.merged_pr) {
|
|
77
|
+
markCiChecked(row.session_id, true);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const rollup = await rollupOf(task, row.merged_pr);
|
|
82
|
+
const verdict = checksVerdict(rollup);
|
|
83
|
+
if (verdict === "fail") {
|
|
84
|
+
queueEventOnce(
|
|
85
|
+
workspaceId,
|
|
86
|
+
`fast-ci:${row.session_id}:${row.merged_sha}`,
|
|
87
|
+
row.session_id,
|
|
88
|
+
`task ${task.name}: merged on the fast lane (PR #${row.merged_pr}), but CI failed after the merge${failingCheck(rollup) ? ` (${failingCheck(rollup)})` : ""}. Fix forward with a task.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
markCiChecked(row.session_id, verdict !== "pending");
|
|
92
|
+
} catch {
|
|
93
|
+
markCiChecked(row.session_id, false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -165,7 +165,12 @@ export function checkGate(
|
|
|
165
165
|
return { gate, state: "pass" };
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
// `fast`: the fast lane keeps Saad's hold, the rules on the diff, the stack
|
|
169
|
+
// and the PR body's attribution; no CI, review or card scope check.
|
|
170
|
+
export function evaluateGates(
|
|
171
|
+
i: GateInput,
|
|
172
|
+
opts: { fast?: boolean } = {}
|
|
173
|
+
): GateOutcome[] {
|
|
169
174
|
const blocked: GateOutcome = i.prBlocked
|
|
170
175
|
? { gate: "blocked", state: "fail", reason: i.prBlocked }
|
|
171
176
|
: i.prDraft
|
|
@@ -185,7 +190,7 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
|
|
|
185
190
|
: { gate: "blocked", state: "pass" };
|
|
186
191
|
const scope: GateOutcome = i.ruleBreaks.length
|
|
187
192
|
? { gate: "scope", state: "fail", reason: i.ruleBreaks.join("; ") }
|
|
188
|
-
: i.fromCard
|
|
193
|
+
: i.fromCard && !opts.fast
|
|
189
194
|
? checkGate("scope", i.scope, i.sha)
|
|
190
195
|
: { gate: "scope", state: "pass" };
|
|
191
196
|
const stack: GateOutcome = i.stackRefusal
|
|
@@ -197,6 +202,7 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
|
|
|
197
202
|
const codeReview: GateOutcome = i.codeReview.refusal
|
|
198
203
|
? { gate: "code-review", state: "fail", reason: i.codeReview.refusal }
|
|
199
204
|
: { gate: "code-review", state: "pass" };
|
|
205
|
+
if (opts.fast) return [codeReview, blocked, scope, stack];
|
|
200
206
|
return [
|
|
201
207
|
ciGate(i),
|
|
202
208
|
checkGate("review", i.review, i.sha),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROJECT_TIERS,
|
|
3
|
+
setProjectTier,
|
|
4
|
+
type ProjectTier,
|
|
5
|
+
} from "../project-tier";
|
|
6
|
+
import { addNote } from "./notes";
|
|
7
|
+
import { workspaceProjects } from "./brief";
|
|
8
|
+
import { workspaceProject } from "./targets";
|
|
9
|
+
|
|
10
|
+
const line = (name: string, tier: ProjectTier) =>
|
|
11
|
+
`- ${name}: ${tier}${tier === "fast" ? " (merges when its PR opens)" : " (CI, review and scope gate its merges)"}`;
|
|
12
|
+
|
|
13
|
+
// project_tier: read one project's tier, or all of the workspace's; with a
|
|
14
|
+
// tier, set it (Saad's call: only when he says so).
|
|
15
|
+
export function projectTierTool(
|
|
16
|
+
workspaceId: string,
|
|
17
|
+
projectRef?: string,
|
|
18
|
+
tier?: (typeof PROJECT_TIERS)[number]
|
|
19
|
+
): string {
|
|
20
|
+
if (!projectRef) {
|
|
21
|
+
if (tier) throw new Error("Say which project to set");
|
|
22
|
+
const all = workspaceProjects(workspaceId);
|
|
23
|
+
return all.length
|
|
24
|
+
? all.map((p) => line(p.name, p.tier)).join("\n")
|
|
25
|
+
: "No projects in this workspace.";
|
|
26
|
+
}
|
|
27
|
+
const project = workspaceProject(workspaceId, projectRef);
|
|
28
|
+
if (!tier) return line(project.name, project.tier);
|
|
29
|
+
if (tier === project.tier) return line(project.name, project.tier);
|
|
30
|
+
setProjectTier(project.id, tier);
|
|
31
|
+
addNote(workspaceId, `Set ${project.name} to ${tier}.`);
|
|
32
|
+
return `Set ${project.name} to ${tier}.\n${line(project.name, tier)}`;
|
|
33
|
+
}
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { noteQuickReview } from "./quick";
|
|
56
56
|
import { workspaceTask } from "./targets";
|
|
57
57
|
import { untrusted } from "./untrusted";
|
|
58
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
58
59
|
|
|
59
60
|
const short = (sha: string) => sha.slice(0, 7);
|
|
60
61
|
// The most diff one reviewer is given, and the most parts a change too big
|
|
@@ -267,6 +268,8 @@ export async function reviewAt(
|
|
|
267
268
|
sha: string,
|
|
268
269
|
opts: ReviewOpts = {}
|
|
269
270
|
): Promise<string> {
|
|
271
|
+
if (target.task && isFast(target.task))
|
|
272
|
+
return `${target.task.name} is in a fast project: no independent review; it merges through the fast gates.`;
|
|
270
273
|
const known = getCheck(target.id, sha, "review");
|
|
271
274
|
// A card task's scope check runs after its review; one never stored (a
|
|
272
275
|
// restart between the two) means running the job again.
|
|
@@ -26,6 +26,7 @@ import { sessionFacts } from "./facts";
|
|
|
26
26
|
import { describeExternal, externalSessions } from "./external-sessions";
|
|
27
27
|
import { resolveStaleAsks } from "./ask-settle";
|
|
28
28
|
import { addNote } from "./notes";
|
|
29
|
+
import { projectTierTool } from "./project-tier-tool";
|
|
29
30
|
import { addProject } from "./add-project";
|
|
30
31
|
import { addRule, removeRule } from "./rules";
|
|
31
32
|
import { askSaad } from "./ask-tool";
|
|
@@ -237,6 +238,10 @@ export async function runTool(
|
|
|
237
238
|
const a = p(tool, raw);
|
|
238
239
|
return addProject(w, a.path, a.name);
|
|
239
240
|
}
|
|
241
|
+
case "project_tier": {
|
|
242
|
+
const a = p(tool, raw);
|
|
243
|
+
return projectTierTool(w, a.project, a.tier);
|
|
244
|
+
}
|
|
240
245
|
case "note":
|
|
241
246
|
addNote(w, p(tool, raw).text);
|
|
242
247
|
return "Noted.";
|
|
@@ -277,7 +282,7 @@ export async function runTool(
|
|
|
277
282
|
return signOffTarget(w, a.task, { direct: a.direct, reason: a.reason });
|
|
278
283
|
}
|
|
279
284
|
case "arm":
|
|
280
|
-
return arm(w, p(tool, raw).task);
|
|
285
|
+
return await arm(w, p(tool, raw).task);
|
|
281
286
|
case "disarm":
|
|
282
287
|
return disarm(w, p(tool, raw).task);
|
|
283
288
|
case "ask_saad":
|
|
@@ -231,6 +231,9 @@ export function setup(
|
|
|
231
231
|
workingDirectory: repo,
|
|
232
232
|
});
|
|
233
233
|
setProjectWorkspace(project.id, workspace.id);
|
|
234
|
+
db.prepare(`UPDATE projects SET tier = 'production' WHERE id = ?`).run(
|
|
235
|
+
project.id
|
|
236
|
+
);
|
|
234
237
|
ensureOrchestrator(workspace.id);
|
|
235
238
|
const task = seedSession({
|
|
236
239
|
projectId: project.id,
|
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
reviewSignature,
|
|
42
42
|
} from "./loop-guard";
|
|
43
43
|
import { addNote } from "./notes";
|
|
44
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
45
|
+
import { recordFastMerge } from "./fast-lane-state";
|
|
44
46
|
import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
|
|
45
47
|
import { workSubject } from "./asks";
|
|
46
48
|
import { carryApproval } from "./approval-carry";
|
|
@@ -71,7 +73,7 @@ export function restackedOf(task: Session) {
|
|
|
71
73
|
|
|
72
74
|
// `approval`: Saad approved this merge on his asks list, for this commit.
|
|
73
75
|
export type Verdict =
|
|
74
|
-
| { ok: true; sha: string; pr: number; approval?: number }
|
|
76
|
+
| { ok: true; sha: string; pr: number; approval?: number; fast?: true }
|
|
75
77
|
| { ok: false; wait: boolean; text: string };
|
|
76
78
|
|
|
77
79
|
// Whether the task may merge now, and at which commit. Landing merges a
|
|
@@ -107,13 +109,15 @@ export async function judge(
|
|
|
107
109
|
// A PR quick_pr opened (by the row AgentOS wrote, never the body): its
|
|
108
110
|
// code review is the independent review of this head, and until that
|
|
109
111
|
// passes it waits, before any gate is judged or counted.
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
const fast = isFast(task);
|
|
113
|
+
const quick =
|
|
114
|
+
isQuick(task) && !fast
|
|
115
|
+
? quickCodeReview(
|
|
116
|
+
sha,
|
|
117
|
+
getCheck(task.id, sha, "review"),
|
|
118
|
+
restackedOf(task)?.to === sha
|
|
119
|
+
)
|
|
120
|
+
: null;
|
|
117
121
|
// Saad's approval of this head stands in for that review (gateVerdict).
|
|
118
122
|
if (
|
|
119
123
|
quick?.wait &&
|
|
@@ -157,7 +161,7 @@ export async function judge(
|
|
|
157
161
|
return quick ? { refusal: gate.refusal, wait: null } : gate;
|
|
158
162
|
})(),
|
|
159
163
|
}),
|
|
160
|
-
opts
|
|
164
|
+
{ ...opts, fast }
|
|
161
165
|
);
|
|
162
166
|
}
|
|
163
167
|
|
|
@@ -178,8 +182,9 @@ export async function gateVerdict(
|
|
|
178
182
|
workspaceId: string,
|
|
179
183
|
work: GateWork,
|
|
180
184
|
inputs: () => Promise<Omit<GateInput, "sha" | "ruleBreaks">>,
|
|
181
|
-
opts: { count?: boolean } = {}
|
|
185
|
+
opts: { count?: boolean; fast?: boolean } = {}
|
|
182
186
|
): Promise<Verdict> {
|
|
187
|
+
const fast = !!opts.fast;
|
|
183
188
|
const no = (text: string, wait = false): Verdict => ({
|
|
184
189
|
ok: false,
|
|
185
190
|
wait,
|
|
@@ -216,7 +221,9 @@ export async function gateVerdict(
|
|
|
216
221
|
: null;
|
|
217
222
|
const refusal = i.codeReview.refusal ?? stack;
|
|
218
223
|
if (refusal) return no(`${approved} ${refusal}`);
|
|
219
|
-
const ci =
|
|
224
|
+
const ci = fast
|
|
225
|
+
? { state: "pass" as const }
|
|
226
|
+
: ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
|
|
220
227
|
if (ci.state === "fail") return no(`${approved} ${ci.reason}`);
|
|
221
228
|
if (ci.state === "wait") return no(`${approved} ${ci.reason}`, true);
|
|
222
229
|
// "escalate" is a repository with no CI: Saad is who that goes to.
|
|
@@ -245,7 +252,7 @@ export async function gateVerdict(
|
|
|
245
252
|
}
|
|
246
253
|
// Here too, not only in the review: one reviewed in parts while approvals
|
|
247
254
|
// were off goes to Saad once they're on.
|
|
248
|
-
if (mergeApprovalsOn()) {
|
|
255
|
+
if (mergeApprovalsOn() && !fast) {
|
|
249
256
|
const size = (await fullDiff(repo, base, sha)).length;
|
|
250
257
|
if (size > DIFF_CAP) {
|
|
251
258
|
const why = `PR #${pr.number}'s diff at ${short(sha)} is ${size} characters, too big to review whole`;
|
|
@@ -255,8 +262,11 @@ export async function gateVerdict(
|
|
|
255
262
|
|
|
256
263
|
const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
|
|
257
264
|
const live = await inputs();
|
|
258
|
-
const repoCI = await repoCIFor(repo, base, live);
|
|
259
|
-
const outcomes = evaluateGates(
|
|
265
|
+
const repoCI = fast ? undefined : await repoCIFor(repo, base, live);
|
|
266
|
+
const outcomes = evaluateGates(
|
|
267
|
+
{ sha, ruleBreaks: ruled, ...live, repoCI },
|
|
268
|
+
{ fast }
|
|
269
|
+
);
|
|
260
270
|
const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
|
|
261
271
|
const toSaad = outcomes.find((o) => o.state === "escalate");
|
|
262
272
|
if (toSaad)
|
|
@@ -266,9 +276,12 @@ export async function gateVerdict(
|
|
|
266
276
|
const failed = outcomes.filter((o) => o.state === "fail");
|
|
267
277
|
const waiting = outcomes.filter((o) => o.state === "wait");
|
|
268
278
|
if (!failed.length && !waiting.length)
|
|
269
|
-
return
|
|
279
|
+
return fast
|
|
280
|
+
? { ok: true, sha, pr: pr.number, fast: true }
|
|
281
|
+
: { ok: true, sha, pr: pr.number };
|
|
270
282
|
const lines: string[] = [];
|
|
271
|
-
|
|
283
|
+
// The fast lane has no loop guard: a failure is told, never counted.
|
|
284
|
+
if (opts.count === false || fast)
|
|
272
285
|
lines.push(...failed.map((o) => `- ${o.gate} failed: ${o.reason}`));
|
|
273
286
|
else {
|
|
274
287
|
for (const o of outcomes)
|
|
@@ -346,11 +359,15 @@ export async function mergeJudged(
|
|
|
346
359
|
await refundIfRefused(task.id, verdict.approval, async () => {
|
|
347
360
|
method = await signOffTask(task.id, { head: verdict.sha, wait: opts.wait });
|
|
348
361
|
});
|
|
362
|
+
if (verdict.fast)
|
|
363
|
+
recordFastMerge(task.id, workspaceId, verdict.pr, verdict.sha);
|
|
349
364
|
addNote(
|
|
350
365
|
workspaceId,
|
|
351
|
-
verdict.
|
|
352
|
-
? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on
|
|
353
|
-
:
|
|
366
|
+
verdict.fast
|
|
367
|
+
? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on the fast lane: no CI wait or review.`
|
|
368
|
+
: verdict.approval
|
|
369
|
+
? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
|
|
370
|
+
: `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
|
|
354
371
|
);
|
|
355
372
|
return `Merged ${task.name}: PR #${verdict.pr} ${method ? MERGED_AS[method] : "merged"} at ${short(verdict.sha)}.`;
|
|
356
373
|
}
|
|
@@ -40,6 +40,7 @@ export const TOOL_NAMES = {
|
|
|
40
40
|
archive: name("archive"),
|
|
41
41
|
resurrect: name("resurrect"),
|
|
42
42
|
add_project: name("add_project"),
|
|
43
|
+
project_tier: name("project_tier"),
|
|
43
44
|
note: name("note"),
|
|
44
45
|
question: name("question"),
|
|
45
46
|
resolve_question: name("resolve_question"),
|
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { BRANCH_NAME } from "../git";
|
|
6
6
|
import { RAISED_KINDS } from "./asks";
|
|
7
7
|
import { MAX_RULE_CHARS } from "./rules";
|
|
8
|
+
import { PROJECT_TIERS } from "../project-tier/types";
|
|
8
9
|
import { PROJECT_NAME } from "../project-add/name";
|
|
9
10
|
import { TASK_ROLES, type ModelRole } from "../models/roles";
|
|
10
11
|
import { PRIORITIES } from "../tasks/schedule-types";
|
|
@@ -160,8 +161,9 @@ export const TOOL_SHAPES = {
|
|
|
160
161
|
model: modelOverride,
|
|
161
162
|
plan: z
|
|
162
163
|
.boolean()
|
|
164
|
+
.optional()
|
|
163
165
|
.describe(
|
|
164
|
-
"Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
|
|
166
|
+
"Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself. Default: false in a fast project, the workspace's setting in a production one"
|
|
165
167
|
),
|
|
166
168
|
plan_only: z
|
|
167
169
|
.boolean()
|
|
@@ -174,6 +176,7 @@ export const TOOL_SHAPES = {
|
|
|
174
176
|
.trim()
|
|
175
177
|
.min(3)
|
|
176
178
|
.max(200)
|
|
179
|
+
.optional()
|
|
177
180
|
.describe(
|
|
178
181
|
'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
|
|
179
182
|
),
|
|
@@ -338,6 +341,10 @@ export const TOOL_SHAPES = {
|
|
|
338
341
|
.optional()
|
|
339
342
|
.describe("A new project's name (default: the folder's)"),
|
|
340
343
|
},
|
|
344
|
+
project_tier: {
|
|
345
|
+
project: z.string().trim().min(1).max(200).optional(),
|
|
346
|
+
tier: z.enum(PROJECT_TIERS).optional(),
|
|
347
|
+
},
|
|
341
348
|
note: { text: z.string().trim().min(1).max(2000) },
|
|
342
349
|
question: {
|
|
343
350
|
text: z
|
|
@@ -165,6 +165,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
165
165
|
"Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
|
|
166
166
|
add_project:
|
|
167
167
|
"Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
|
|
168
|
+
project_tier:
|
|
169
|
+
"Read or set a project's tier. production: every merge gate applies (CI, independent review, scope). fast: a task's PR merges as soon as it opens, with no CI wait or review. Without a project, lists this workspace's; with a project, reads it; with a tier too, sets it, only when Saad says to.",
|
|
168
170
|
note: "Add a line to this workspace's decision log; it shows in your chat.",
|
|
169
171
|
rule_add:
|
|
170
172
|
'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* every 15s while anything in the workspace is working, every 60s when not.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { autoArmFast, followFastMerges } from "./fast-lane";
|
|
8
9
|
import { chatState, chatStateNow, sendChat } from "../chat/runner";
|
|
9
10
|
import type { ChatState } from "../chat/events";
|
|
10
11
|
import { getWorkspace } from "../workspaces";
|
|
@@ -96,7 +97,11 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
|
|
|
96
97
|
console.error(`Judging asked commits in ${workspaceId}:`, error)
|
|
97
98
|
);
|
|
98
99
|
await rebaseConflicting(workspaceId, facts);
|
|
100
|
+
await autoArmFast(workspaceId, facts);
|
|
99
101
|
await mergeArmed(workspaceId, facts);
|
|
102
|
+
await followFastMerges(workspaceId).catch((error) =>
|
|
103
|
+
console.error(`Following fast merges in ${workspaceId}:`, error)
|
|
104
|
+
);
|
|
100
105
|
})()
|
|
101
106
|
.catch((error) =>
|
|
102
107
|
console.error(`Merge pipeline for ${workspaceId}:`, error)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import type { ProjectTier } from "./types";
|
|
3
|
+
|
|
4
|
+
// An unknown project counts as production: a missing row never opens the
|
|
5
|
+
// fast lane.
|
|
6
|
+
export function projectTier(projectId: string | null): ProjectTier {
|
|
7
|
+
if (!projectId) return "production";
|
|
8
|
+
const row = db
|
|
9
|
+
.prepare(`SELECT tier FROM projects WHERE id = ?`)
|
|
10
|
+
.get(projectId) as { tier: string } | undefined;
|
|
11
|
+
return row?.tier === "fast" ? "fast" : "production";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const taskTier = (task: { project_id: string | null }): ProjectTier =>
|
|
15
|
+
projectTier(task.project_id);
|
|
16
|
+
|
|
17
|
+
export const isFast = (task: { project_id: string | null }): boolean =>
|
|
18
|
+
taskTier(task) === "fast";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getProject } from "@/lib/projects";
|
|
2
|
+
import { db } from "@/lib/db";
|
|
3
|
+
import type { Project } from "@/lib/db/types";
|
|
4
|
+
import type { ProjectTier } from "./types";
|
|
5
|
+
|
|
6
|
+
export function setProjectTier(projectId: string, tier: ProjectTier): Project {
|
|
7
|
+
const project = getProject(projectId);
|
|
8
|
+
if (!project || project.is_uncategorized)
|
|
9
|
+
throw new Error("Project not found or cannot be modified");
|
|
10
|
+
db.prepare(
|
|
11
|
+
`UPDATE projects SET tier = ?, updated_at = datetime('now') WHERE id = ?`
|
|
12
|
+
).run(tier, projectId);
|
|
13
|
+
return getProject(projectId)!;
|
|
14
|
+
}
|
package/lib/start-options.ts
CHANGED
|
@@ -40,7 +40,14 @@ export async function startOptions(
|
|
|
40
40
|
const opts = { workspaceId: project?.workspace_id ?? null };
|
|
41
41
|
roles.plan = resolveRole("plan", opts, models).ref;
|
|
42
42
|
roles.implement = resolveRole("implement", opts, models).ref;
|
|
43
|
-
planDefault = taskRouting(
|
|
43
|
+
planDefault = taskRouting(
|
|
44
|
+
opts.workspaceId,
|
|
45
|
+
"chat",
|
|
46
|
+
undefined,
|
|
47
|
+
undefined,
|
|
48
|
+
undefined,
|
|
49
|
+
project?.tier
|
|
50
|
+
).plan;
|
|
44
51
|
} catch (error) {
|
|
45
52
|
planError = error instanceof Error ? error.message : String(error);
|
|
46
53
|
}
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { sessionEnded } from "../experiments/hooks";
|
|
|
40
40
|
import { snapshotWorktree } from "../resurrect/snapshot";
|
|
41
41
|
import { keepSessionFiles } from "../session-artifacts/collect";
|
|
42
42
|
import { setPr, setTask } from "../db/session-parts";
|
|
43
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
43
44
|
|
|
44
45
|
export const sessionMergePolicy = (session: Session): MergePolicy =>
|
|
45
46
|
mergePolicy(session.project_id ? getProject(session.project_id) : null);
|
|
@@ -167,7 +168,7 @@ export async function signOffTask(
|
|
|
167
168
|
throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
|
|
168
169
|
const judgedBase = opts.base ?? session.base_branch;
|
|
169
170
|
const pr = await prFor(session, true);
|
|
170
|
-
const verdict = canSignOff(pr);
|
|
171
|
+
const verdict = canSignOff(pr, { ci: !isFast(session) });
|
|
171
172
|
if (!verdict.ok) throw new Error(verdict.reason);
|
|
172
173
|
if (pr!.base && pr!.base !== judgedBase)
|
|
173
174
|
throw new Error(
|
package/lib/tasks/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { effectiveModels } from "../models/config";
|
|
|
15
15
|
import { ANTHROPIC } from "../models/roles";
|
|
16
16
|
import { processTable, runsSomething } from "../process-table";
|
|
17
17
|
import { randomUUID } from "crypto";
|
|
18
|
-
import { db, queries, type Session } from "../db";
|
|
18
|
+
import { db, queries, type ProjectTier, type Session } from "../db";
|
|
19
19
|
import { getProject } from "../projects";
|
|
20
20
|
import {
|
|
21
21
|
createWorktree,
|
|
@@ -267,7 +267,8 @@ async function startTask(
|
|
|
267
267
|
view,
|
|
268
268
|
opts.model,
|
|
269
269
|
opts.plan,
|
|
270
|
-
opts.planOnly
|
|
270
|
+
opts.planOnly,
|
|
271
|
+
project.tier
|
|
271
272
|
).plan;
|
|
272
273
|
const session = await startRemoteTask(hostId, project, {
|
|
273
274
|
id,
|
|
@@ -310,7 +311,8 @@ async function startTask(
|
|
|
310
311
|
view,
|
|
311
312
|
opts.model,
|
|
312
313
|
opts.plan,
|
|
313
|
-
opts.planOnly
|
|
314
|
+
opts.planOnly,
|
|
315
|
+
project.tier
|
|
314
316
|
)
|
|
315
317
|
: otherAgentRouting(opts.model);
|
|
316
318
|
const projectPath = expandHome(project.working_directory);
|
|
@@ -590,7 +592,9 @@ export function taskRouting(
|
|
|
590
592
|
view: StartView,
|
|
591
593
|
model: unknown,
|
|
592
594
|
plan?: boolean,
|
|
593
|
-
planOnly?: boolean
|
|
595
|
+
planOnly?: boolean,
|
|
596
|
+
// A fast project skips the plan step unless the caller asks for it.
|
|
597
|
+
tier: ProjectTier = "production"
|
|
594
598
|
) {
|
|
595
599
|
const override = parseOverride(model);
|
|
596
600
|
const models = effectiveModels();
|
|
@@ -601,7 +605,8 @@ export function taskRouting(
|
|
|
601
605
|
const planStep =
|
|
602
606
|
view === "chat" &&
|
|
603
607
|
(!!planOnly ||
|
|
604
|
-
((plan ??
|
|
608
|
+
((plan ?? (tier === "fast" ? false : models.planStep)) &&
|
|
609
|
+
planning.ref !== implement.ref));
|
|
605
610
|
return {
|
|
606
611
|
override,
|
|
607
612
|
plan: planStep,
|
package/lib/tasks/state.ts
CHANGED
|
@@ -97,13 +97,19 @@ export function needsHuman(state: TaskState): boolean {
|
|
|
97
97
|
].includes(state);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
// `ci: false` (a fast project): checks that are failing or running don't
|
|
101
|
+
// refuse the merge.
|
|
102
|
+
export function canSignOff(
|
|
103
|
+
pr: TaskPR | null,
|
|
104
|
+
opts: { ci?: boolean } = {}
|
|
105
|
+
): {
|
|
101
106
|
ok: boolean;
|
|
102
107
|
reason?: string;
|
|
103
108
|
} {
|
|
104
109
|
if (!pr) return { ok: false, reason: "No pull request yet" };
|
|
105
110
|
if (pr.state !== "OPEN")
|
|
106
111
|
return { ok: false, reason: `PR is ${pr.state.toLowerCase()}` };
|
|
112
|
+
if (opts.ci === false) return { ok: true };
|
|
107
113
|
if (pr.checks === "fail")
|
|
108
114
|
return { ok: false, reason: "CI checks are failing" };
|
|
109
115
|
if (pr.checks === "pending")
|