@ashulab/carrier 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +264 -2
- package/dist/chat/claude.js +367 -0
- package/dist/chat/codex.js +375 -0
- package/dist/chat/config.js +61 -0
- package/dist/chat/db.js +9 -0
- package/dist/chat/fake.js +203 -0
- package/dist/chat/folder.js +56 -0
- package/dist/chat/git.js +27 -0
- package/dist/chat/group.js +28 -0
- package/dist/chat/health.js +49 -0
- package/dist/chat/host.js +123 -0
- package/dist/chat/idle.js +20 -0
- package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
- package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
- package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
- package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
- package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
- package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
- package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
- package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
- package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
- package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
- package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
- package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
- package/dist/chat/migrations/meta/_journal.json +48 -0
- package/dist/chat/names.js +97 -0
- package/dist/chat/plan.js +21 -0
- package/dist/chat/projects.js +242 -0
- package/dist/chat/runner.js +6 -0
- package/dist/chat/schema.js +48 -0
- package/dist/chat/server.js +202 -0
- package/dist/chat/sessions.js +766 -0
- package/dist/chat/sweep.js +64 -0
- package/dist/chat/transcript.js +31 -0
- package/dist/chat/words.js +20 -0
- package/dist/chat/workspace.js +134 -0
- package/dist/chat/worktree.js +142 -0
- package/dist/config/default-path.js +7 -0
- package/dist/config/engine-places.js +9 -0
- package/dist/config/json-schema.js +15 -0
- package/dist/config/load.js +48 -0
- package/dist/config/resolve.js +41 -0
- package/dist/config/schema.js +225 -0
- package/dist/control/epic.js +219 -0
- package/dist/control/events.js +48 -0
- package/dist/control/files.js +262 -0
- package/dist/control/replies.js +1 -0
- package/dist/control/server.js +237 -0
- package/dist/control/sessions.js +68 -0
- package/dist/doctor/doctor.js +255 -0
- package/dist/doctor/fixes.js +35 -0
- package/dist/execution/agent-runner.js +1 -0
- package/dist/execution/caller-result.js +61 -0
- package/dist/execution/events.js +19 -0
- package/dist/execution/failure.js +3 -0
- package/dist/execution/fake.js +82 -0
- package/dist/execution/git-workspaces.js +136 -0
- package/dist/execution/import-result.js +23 -0
- package/dist/execution/workspaces.js +1 -0
- package/dist/format/duration.js +12 -0
- package/dist/harnesses/agy.js +131 -0
- package/dist/harnesses/claude.js +246 -0
- package/dist/harnesses/codex.js +118 -0
- package/dist/harnesses/common.js +59 -0
- package/dist/harnesses/credentials.js +38 -0
- package/dist/harnesses/host-runner.js +225 -0
- package/dist/harnesses/local.js +25 -0
- package/dist/harnesses/names.js +9 -0
- package/dist/harnesses/replay-events.js +57 -0
- package/dist/integrations/available-repos.js +29 -0
- package/dist/integrations/forge-cwd.js +27 -0
- package/dist/integrations/github-graphql.js +216 -0
- package/dist/integrations/github.js +231 -0
- package/dist/logging/logger.js +53 -0
- package/dist/logging/redact.js +21 -0
- package/dist/orchestration/blockers.js +25 -0
- package/dist/orchestration/changes-slices.js +5 -0
- package/dist/orchestration/clock.js +7 -0
- package/dist/orchestration/engine.js +1068 -0
- package/dist/orchestration/fake.js +152 -0
- package/dist/orchestration/final-pr.js +15 -0
- package/dist/orchestration/github.js +31 -0
- package/dist/orchestration/reserve.js +32 -0
- package/dist/orchestration/resume-refused.js +7 -0
- package/dist/orchestration/retention.js +145 -0
- package/dist/orchestration/retry.js +16 -0
- package/dist/prompts/build-prompt.js +13 -0
- package/dist/prompts/fix.js +21 -0
- package/dist/prompts/intro.js +21 -0
- package/dist/prompts/invoke.js +2 -0
- package/dist/prompts/persona.js +12 -0
- package/dist/prompts/result.js +32 -0
- package/dist/prompts/review.js +15 -0
- package/dist/prompts/ship.js +8 -0
- package/dist/status/snapshot.js +92 -0
- package/dist/status/types.js +1 -0
- package/dist/storage/caller-results.js +7 -0
- package/dist/storage/db.js +8 -0
- package/dist/storage/epic-review-threads.js +19 -0
- package/dist/storage/epic-slices.js +17 -0
- package/dist/storage/events.js +52 -0
- package/dist/storage/executions.js +31 -0
- package/dist/storage/lock.js +21 -0
- package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
- package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
- package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
- package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
- package/dist/storage/migrations/0004_long_cerise.sql +18 -0
- package/dist/storage/migrations/0005_review_result.sql +2 -0
- package/dist/storage/migrations/0006_melted_menace.sql +10 -0
- package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
- package/dist/storage/migrations/0008_panel_state.sql +4 -0
- package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
- package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
- package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
- package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
- package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
- package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
- package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
- package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
- package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
- package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
- package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
- package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
- package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
- package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
- package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
- package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
- package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
- package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
- package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
- package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
- package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
- package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
- package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
- package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
- package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
- package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
- package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
- package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
- package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
- package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
- package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
- package/dist/storage/migrations/meta/_journal.json +146 -0
- package/dist/storage/schema.js +43 -0
- package/dist/terminal/cli/args.js +91 -0
- package/dist/terminal/cli/cli-error.js +11 -0
- package/dist/terminal/cli/commands/chat-host.js +18 -0
- package/dist/terminal/cli/commands/chat.js +321 -0
- package/dist/terminal/cli/commands/control.js +46 -0
- package/dist/terminal/cli/commands/doctor.js +15 -0
- package/dist/terminal/cli/commands/init.js +43 -0
- package/dist/terminal/cli/commands/restart.js +92 -0
- package/dist/terminal/cli/commands/run.js +20 -0
- package/dist/terminal/cli/commands/schema.js +4 -0
- package/dist/terminal/cli/commands/start.js +21 -0
- package/dist/terminal/cli/commands/status.js +61 -0
- package/dist/terminal/cli/commands/watch.js +112 -0
- package/dist/terminal/cli/compose.js +163 -0
- package/dist/terminal/cli/config-file.js +24 -0
- package/dist/terminal/cli/control-client.js +94 -0
- package/dist/terminal/cli/detached.js +41 -0
- package/dist/terminal/cli/interrupt.js +39 -0
- package/dist/terminal/cli/main.js +85 -0
- package/dist/terminal/cli/open-url.js +9 -0
- package/dist/terminal/dashboard/dashboard.js +204 -0
- package/dist/terminal/dashboard/history.js +38 -0
- package/dist/terminal/dashboard/interactive.js +60 -0
- package/dist/terminal/dashboard/keys.js +22 -0
- package/dist/terminal/dashboard/logs.js +85 -0
- package/dist/terminal/dashboard/panel.js +62 -0
- package/dist/terminal/dashboard/plain.js +32 -0
- package/dist/terminal/dashboard/printable.js +2 -0
- package/dist/terminal/dashboard/ui-state.js +110 -0
- package/dist/terminal/prompts/clack.js +121 -0
- package/dist/terminal/prompts/types.js +3 -0
- package/dist/terminal/wizard/build-config.js +66 -0
- package/dist/terminal/wizard/deps.js +94 -0
- package/dist/terminal/wizard/flags.js +1 -0
- package/dist/terminal/wizard/forge-registry.js +23 -0
- package/dist/terminal/wizard/prior.js +20 -0
- package/dist/terminal/wizard/questions/agent.js +61 -0
- package/dist/terminal/wizard/questions/identities.js +28 -0
- package/dist/terminal/wizard/questions/identity.js +30 -0
- package/dist/terminal/wizard/questions/options.js +93 -0
- package/dist/terminal/wizard/questions/out.js +16 -0
- package/dist/terminal/wizard/questions/persona.js +47 -0
- package/dist/terminal/wizard/questions/repos.js +61 -0
- package/dist/terminal/wizard/summary.js +31 -0
- package/dist/terminal/wizard/testing.js +106 -0
- package/dist/terminal/wizard/wizard.js +172 -0
- package/package.json +46 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { LogLevel } from '@ashulab/universal-logger';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { harnessNames, localProvidersOf } from '../harnesses/names.js';
|
|
5
|
+
import { expandHome } from './default-path.js';
|
|
6
|
+
import { resolveRepoConfig } from './resolve.js';
|
|
7
|
+
/** `owner/name`, each part a plain folder name: the managed clone lives in `repos/<owner>/<name>`. */
|
|
8
|
+
export const repoNamePattern = /^(?!\.{1,2}\/)(?!.*\/\.{1,2}$)[\w.-]+\/[\w.-]+$/;
|
|
9
|
+
/** Who the agent of a role says it is: a one-line name (it ends up in GitHub text) and a prompt. A repo's persona replaces the global one whole. */
|
|
10
|
+
export const persona = z.strictObject({
|
|
11
|
+
name: z.string().trim().min(1).max(40).regex(/^[^\r\n]+$/).optional(),
|
|
12
|
+
prompt: z.string().trim().min(1).max(4000).optional(),
|
|
13
|
+
});
|
|
14
|
+
/** An AI provider account / connection. */
|
|
15
|
+
export const aiProvider = z.strictObject({
|
|
16
|
+
harness: z.enum(harnessNames),
|
|
17
|
+
/** Optional default model for this provider connection. */
|
|
18
|
+
model: z.string().min(1).optional(),
|
|
19
|
+
/** Run on a local server through the harness's CLI instead of the cloud. */
|
|
20
|
+
provider: z.enum(['lmstudio', 'ollama']).optional(),
|
|
21
|
+
/** `CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex. Absolute once `~` is expanded. */
|
|
22
|
+
configDir: z.string().min(1).transform(expandHome).optional(),
|
|
23
|
+
});
|
|
24
|
+
/** The key of a provider or option: stored and shown as is. */
|
|
25
|
+
export const optionNamePattern = /^[a-z][a-z0-9-]{0,39}$/;
|
|
26
|
+
// A role keeps its GitHub identity and persona, and points at an AI provider and model.
|
|
27
|
+
const agent = z.strictObject({
|
|
28
|
+
persona: persona.optional(),
|
|
29
|
+
profile: z.string().min(1),
|
|
30
|
+
provider: z.string().min(1),
|
|
31
|
+
model: z.string().min(1),
|
|
32
|
+
});
|
|
33
|
+
// The two shapes the spike fixed (docs/research/agent-forge.md): a bot through Agent Forge, or the user's own `gh` login. No secrets here.
|
|
34
|
+
const profile = z.discriminatedUnion('kind', [
|
|
35
|
+
z.strictObject({ kind: z.literal('forge'), agent: z.string().min(1) }),
|
|
36
|
+
z.strictObject({ kind: z.literal('gh') }),
|
|
37
|
+
]);
|
|
38
|
+
export const failoverTriggers = ['usage_limit', 'rate_limit', 'service_down'];
|
|
39
|
+
export function matchesFailoverTrigger(triggers, failureKind, failureDetail) {
|
|
40
|
+
if (!failureKind)
|
|
41
|
+
return false;
|
|
42
|
+
if (triggers.includes('usage_limit') && failureKind === 'usage_limit')
|
|
43
|
+
return true;
|
|
44
|
+
const isRateLimit = failureKind === 'api_error' && (failureDetail?.includes('429') || /rate[-_ ]?limit|overloaded/i.test(failureDetail ?? ''));
|
|
45
|
+
if (triggers.includes('rate_limit') && isRateLimit)
|
|
46
|
+
return true;
|
|
47
|
+
if (triggers.includes('service_down') && (failureKind === 'spawn_error' || (failureKind === 'api_error' && !isRateLimit)))
|
|
48
|
+
return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const failoverSchema = z.strictObject({
|
|
52
|
+
enabled: z.boolean(),
|
|
53
|
+
triggers: z.array(z.enum(failoverTriggers)).min(1).optional(),
|
|
54
|
+
default: z.array(z.string()).optional(),
|
|
55
|
+
roles: z
|
|
56
|
+
.strictObject({
|
|
57
|
+
author: z.array(z.string()).optional(),
|
|
58
|
+
reviewer: z.array(z.string()).optional(),
|
|
59
|
+
leadReviewer: z.array(z.string()).optional(),
|
|
60
|
+
})
|
|
61
|
+
.partial()
|
|
62
|
+
.optional(),
|
|
63
|
+
});
|
|
64
|
+
export const configSchema = z
|
|
65
|
+
.strictObject({
|
|
66
|
+
pollIntervalMinutes: z.number().positive(),
|
|
67
|
+
finishedWindowDays: z.int().min(1).max(30),
|
|
68
|
+
logLevel: z.enum(LogLevel),
|
|
69
|
+
/** `shared`: the author and the reviewer may be the same GitHub profile (ADR 0011). */
|
|
70
|
+
identities: z.enum(['separate', 'shared']),
|
|
71
|
+
repos: z
|
|
72
|
+
.array(z.strictObject({
|
|
73
|
+
name: z.string().regex(repoNamePattern, 'must be owner/name'),
|
|
74
|
+
agents: z.strictObject({ author: agent.partial(), reviewer: agent.partial(), leadReviewer: agent.partial().optional() }).partial().optional(),
|
|
75
|
+
}))
|
|
76
|
+
.min(1),
|
|
77
|
+
profiles: z.record(z.string(), profile),
|
|
78
|
+
orchestratorProfile: z.string().min(1),
|
|
79
|
+
providers: z.record(z.string(), aiProvider),
|
|
80
|
+
agents: z.strictObject({ author: agent, reviewer: agent, leadReviewer: agent.optional() }),
|
|
81
|
+
/** Off unless present and enabled. Moves a role to the next AI provider upon usage_limit, rate_limit, or service_down. */
|
|
82
|
+
failover: failoverSchema.optional(),
|
|
83
|
+
limits: z.strictObject({
|
|
84
|
+
maxConcurrentEpics: z.int().positive(),
|
|
85
|
+
maxReviews: z.int().positive(),
|
|
86
|
+
maxExecutionRetries: z.int().nonnegative(),
|
|
87
|
+
maxExecutionHours: z.union([z.literal('unlimited'), z.int().positive()]),
|
|
88
|
+
githubCallTimeoutSeconds: z.int().positive(),
|
|
89
|
+
/** How long a reserved epic waits for its choice of agent options when there are two or more. */
|
|
90
|
+
optionChoiceSeconds: z.int().positive(),
|
|
91
|
+
}),
|
|
92
|
+
})
|
|
93
|
+
.superRefine((config, ctx) => {
|
|
94
|
+
const refs = [
|
|
95
|
+
[['orchestratorProfile'], config.orchestratorProfile],
|
|
96
|
+
[['agents', 'author', 'profile'], config.agents.author.profile],
|
|
97
|
+
[['agents', 'reviewer', 'profile'], config.agents.reviewer.profile],
|
|
98
|
+
];
|
|
99
|
+
if (config.agents.leadReviewer)
|
|
100
|
+
refs.push([['agents', 'leadReviewer', 'profile'], config.agents.leadReviewer.profile]);
|
|
101
|
+
config.repos.forEach((repo, i) => {
|
|
102
|
+
for (const role of ['author', 'reviewer', 'leadReviewer']) {
|
|
103
|
+
const profile = repo.agents?.[role]?.profile;
|
|
104
|
+
if (profile !== undefined)
|
|
105
|
+
refs.push([['repos', i, 'agents', role, 'profile'], profile]);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
for (const [path, name] of refs) {
|
|
109
|
+
if (!(name in config.profiles)) {
|
|
110
|
+
ctx.addIssue({ code: 'custom', path, message: `profile "${name}" is not declared in profiles` });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const providerRefs = [
|
|
114
|
+
[['agents', 'author', 'provider'], config.agents.author.provider],
|
|
115
|
+
[['agents', 'reviewer', 'provider'], config.agents.reviewer.provider],
|
|
116
|
+
];
|
|
117
|
+
if (config.agents.leadReviewer)
|
|
118
|
+
providerRefs.push([['agents', 'leadReviewer', 'provider'], config.agents.leadReviewer.provider]);
|
|
119
|
+
config.repos.forEach((repo, i) => {
|
|
120
|
+
for (const role of ['author', 'reviewer', 'leadReviewer']) {
|
|
121
|
+
const provider = repo.agents?.[role]?.provider;
|
|
122
|
+
if (provider !== undefined)
|
|
123
|
+
providerRefs.push([['repos', i, 'agents', role, 'provider'], provider]);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
for (const [path, name] of providerRefs) {
|
|
127
|
+
if (!Object.hasOwn(config.providers, name)) {
|
|
128
|
+
ctx.addIssue({ code: 'custom', path, message: `provider "${name}" is not declared in providers` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (config.failover?.enabled) {
|
|
132
|
+
const hasDefault = config.failover.default && config.failover.default.length > 0;
|
|
133
|
+
const hasRoles = config.failover.roles && Object.values(config.failover.roles).some((r) => r && r.length > 0);
|
|
134
|
+
if (!hasDefault && !hasRoles) {
|
|
135
|
+
ctx.addIssue({ code: 'custom', path: ['failover'], message: 'failover is enabled but has neither a default nor role-specific fallback order' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const checkFailoverList = (list, basePath) => {
|
|
139
|
+
if (!list)
|
|
140
|
+
return;
|
|
141
|
+
const named = new Set();
|
|
142
|
+
list.forEach((name, i) => {
|
|
143
|
+
const p = [...basePath, i];
|
|
144
|
+
const label = basePath.join('.');
|
|
145
|
+
if (!Object.hasOwn(config.providers, name)) {
|
|
146
|
+
ctx.addIssue({ code: 'custom', path: p, message: `${label}: provider "${name}" is not declared in providers` });
|
|
147
|
+
}
|
|
148
|
+
else if (named.has(name)) {
|
|
149
|
+
ctx.addIssue({ code: 'custom', path: p, message: `${label}: provider "${name}" appears twice` });
|
|
150
|
+
}
|
|
151
|
+
named.add(name);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
if (config.failover) {
|
|
155
|
+
checkFailoverList(config.failover.default, ['failover', 'default']);
|
|
156
|
+
if (config.failover.roles) {
|
|
157
|
+
for (const role of ['author', 'reviewer', 'leadReviewer']) {
|
|
158
|
+
checkFailoverList(config.failover.roles[role], ['failover', 'roles', role]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const [name, p] of Object.entries(config.providers)) {
|
|
163
|
+
const where = `providers.${name}`;
|
|
164
|
+
if (!optionNamePattern.test(name)) {
|
|
165
|
+
ctx.addIssue({ code: 'custom', path: ['providers', name], message: `${where}: a provider name is a lowercase word: letters, digits and hyphens, starting with a letter, up to 40 characters` });
|
|
166
|
+
}
|
|
167
|
+
if (p.provider !== undefined && !localProvidersOf[p.harness].includes(p.provider)) {
|
|
168
|
+
ctx.addIssue({ code: 'custom', path: ['providers', name, 'provider'], message: `${where}: ${p.harness} cannot use the provider "${p.provider}"` });
|
|
169
|
+
}
|
|
170
|
+
if (p.configDir !== undefined && p.harness === 'agy') {
|
|
171
|
+
ctx.addIssue({ code: 'custom', path: ['providers', name, 'configDir'], message: `${where}: agy has no config folder setting, so it cannot have a configDir` });
|
|
172
|
+
}
|
|
173
|
+
else if (p.configDir !== undefined && p.configDir !== '' && !isAbsolute(p.configDir)) {
|
|
174
|
+
ctx.addIssue({ code: 'custom', path: ['providers', name, 'configDir'], message: `${where}: configDir must be an absolute path, or start with ~/ (got "${p.configDir}")` });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Solve's worktrees are `worktrees/<repo>/<feature>`, with no owner: same-name repos would share paths.
|
|
178
|
+
const seen = new Map();
|
|
179
|
+
config.repos.forEach((repo, i) => {
|
|
180
|
+
const folder = repo.name.split('/').pop().toLowerCase();
|
|
181
|
+
const first = seen.get(folder);
|
|
182
|
+
if (first === undefined)
|
|
183
|
+
seen.set(folder, repo.name);
|
|
184
|
+
else
|
|
185
|
+
ctx.addIssue({ code: 'custom', path: ['repos', i, 'name'], message: `repo "${repo.name}" has the same folder name as "${first}"; Solve's worktree paths have no owner` });
|
|
186
|
+
if (repo.agents?.leadReviewer && !config.agents.leadReviewer) {
|
|
187
|
+
for (const field of ['provider', 'profile', 'model']) {
|
|
188
|
+
if (!repo.agents.leadReviewer[field]) {
|
|
189
|
+
ctx.addIssue({
|
|
190
|
+
code: 'custom',
|
|
191
|
+
path: ['repos', i, 'agents', 'leadReviewer', field],
|
|
192
|
+
message: `repo "${repo.name}": agents.leadReviewer.${field} is required when no top-level agents.leadReviewer is configured`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// Under separate identities, author and reviewer must differ, globally and for each repo once its overrides are applied.
|
|
199
|
+
if (config.identities !== 'separate')
|
|
200
|
+
return;
|
|
201
|
+
// A global clash is reported once, not again for every repo that inherits it.
|
|
202
|
+
const clash = (path, where, profile, role1 = 'agents.author.profile', role2 = 'agents.reviewer.profile') => ctx.addIssue({
|
|
203
|
+
code: 'custom',
|
|
204
|
+
path,
|
|
205
|
+
message: `${where}${role1} and ${role2} must differ, both are "${profile}" (or set identities to "shared" to use one account for both)`,
|
|
206
|
+
});
|
|
207
|
+
if (config.agents.author.profile === config.agents.reviewer.profile) {
|
|
208
|
+
clash(['agents'], '', config.agents.author.profile);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (config.agents.leadReviewer && config.agents.author.profile === config.agents.leadReviewer.profile) {
|
|
212
|
+
clash(['agents'], '', config.agents.author.profile, 'agents.author.profile', 'agents.leadReviewer.profile');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
config.repos.forEach((repo, i) => {
|
|
216
|
+
const resolved = resolveRepoConfig(config, repo).agents;
|
|
217
|
+
if (!resolved.author || !resolved.reviewer)
|
|
218
|
+
return;
|
|
219
|
+
if (resolved.author.profile === resolved.reviewer.profile)
|
|
220
|
+
clash(['repos', i, 'agents'], `repo "${repo.name}": `, resolved.author.profile);
|
|
221
|
+
else if (resolved.leadReviewer && resolved.author.profile === resolved.leadReviewer.profile) {
|
|
222
|
+
clash(['repos', i, 'agents'], `repo "${repo.name}": `, resolved.author.profile, 'agents.author.profile', 'agents.leadReviewer.profile');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
4
|
+
import { and, desc, eq, sql } from 'drizzle-orm';
|
|
5
|
+
import { callerResults } from '../storage/caller-results.js';
|
|
6
|
+
import { epicReviewThreads } from '../storage/epic-review-threads.js';
|
|
7
|
+
import { epicSlices } from '../storage/epic-slices.js';
|
|
8
|
+
import { executionEvents } from '../storage/events.js';
|
|
9
|
+
import { executions } from '../storage/executions.js';
|
|
10
|
+
import { epics } from '../storage/schema.js';
|
|
11
|
+
import { optionsOf } from '../status/snapshot.js';
|
|
12
|
+
import { agentOf } from './sessions.js';
|
|
13
|
+
const log = createScopedLogger('control');
|
|
14
|
+
// The reply is bounded: with these, a round weighs at most about 60 KB, far under the client's 8 MiB line.
|
|
15
|
+
export const MAX_ROUNDS = 50;
|
|
16
|
+
const MAX_BLOCKERS = 50;
|
|
17
|
+
const MAX_OBSERVATIONS = 50;
|
|
18
|
+
const MAX_COMMITS = 50;
|
|
19
|
+
const MAX_TEXT = 200;
|
|
20
|
+
const MAX_URL = 500;
|
|
21
|
+
const MAX_ACTIVITY = 80;
|
|
22
|
+
const MAX_LIVE_EVENTS = 500; // per kind, newest first, of the one running execution
|
|
23
|
+
/** A title compared without caring for case or spacing. */
|
|
24
|
+
const normal = (title) => title.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
25
|
+
/** The same blocker in two rounds: by id when both have one, else by title. */
|
|
26
|
+
const same = (a, b) => (a.id !== null && b.id !== null ? a.id === b.id : normal(a.title) === normal(b.title));
|
|
27
|
+
/**
|
|
28
|
+
* Blockers of a round against the open ones of the round before it in the same attempt: the same blocker is carried, one that
|
|
29
|
+
* disappeared is fixed, a new one is new. A mark is exact when ids settle it (the first round has nothing to match, so new is certain);
|
|
30
|
+
* otherwise it comes from matching titles and is approximate. `resolved` is what the reviewer said it considers fixed.
|
|
31
|
+
*/
|
|
32
|
+
function marked(current, previous, resolved) {
|
|
33
|
+
const prior = previous ?? [];
|
|
34
|
+
const blocker = (b, mark, approximate) => ({ id: b.id, title: b.title, url: b.url, mark, approximate });
|
|
35
|
+
const allIds = (list) => list.every(({ id }) => id !== null);
|
|
36
|
+
return [
|
|
37
|
+
...current.map((b) => blocker(b, prior.some((p) => same(b, p)) ? 'carried' : 'new', previous !== null && (b.id === null || !allIds(prior)))),
|
|
38
|
+
...prior
|
|
39
|
+
.filter((p) => current.some((b) => same(b, p)) === false)
|
|
40
|
+
.map((p) => blocker(p, 'fixed', !(p.id !== null && (resolved.includes(p.id) || allIds(current))))),
|
|
41
|
+
].slice(0, MAX_BLOCKERS);
|
|
42
|
+
}
|
|
43
|
+
/** git in a folder; null when the folder is gone (expected: an epic's worktree is removed when it ends) or git failed (logged). */
|
|
44
|
+
export function git(cwd, args) {
|
|
45
|
+
if (existsSync(cwd) === false)
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
// no optional locks: reading an agent's worktree must not refresh its index under the agent's own commit
|
|
49
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5_000, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
log.warn('git failed in an epic worktree', { cwd, args: args[0], error: String(error) });
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// A range of two shas never changes, so asking git once is enough (this is polled every few seconds).
|
|
57
|
+
// Oldest entry out past the cap, so a long-lived engine does not grow with every round it ever saw.
|
|
58
|
+
const MAX_CACHED_RANGES = 500;
|
|
59
|
+
const commitCache = new Map();
|
|
60
|
+
/** The commits a fix added between the head the review saw and the head it left; empty when there is no worktree to ask. */
|
|
61
|
+
function commitsBetween(cwd, from, to) {
|
|
62
|
+
if (cwd === null)
|
|
63
|
+
return [];
|
|
64
|
+
const key = `${cwd}\0${from}..${to}`;
|
|
65
|
+
const cached = commitCache.get(key);
|
|
66
|
+
if (cached)
|
|
67
|
+
return cached;
|
|
68
|
+
const out = git(cwd, ['log', `--max-count=${MAX_COMMITS}`, '--format=%H%x00%s', `${from}..${to}`]);
|
|
69
|
+
if (out === null)
|
|
70
|
+
return [];
|
|
71
|
+
const commits = out.split('\n').filter(Boolean).map((line) => {
|
|
72
|
+
const [sha = '', subject = ''] = line.split('\0');
|
|
73
|
+
return { sha, subject: subject.slice(0, MAX_TEXT) };
|
|
74
|
+
});
|
|
75
|
+
if (commitCache.size >= MAX_CACHED_RANGES)
|
|
76
|
+
commitCache.delete(commitCache.keys().next().value);
|
|
77
|
+
commitCache.set(key, commits);
|
|
78
|
+
return commits;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The lane and the activity of the agent building each slice, by slice number: the agent is the one whose newest `task` event
|
|
82
|
+
* names the slice and has no status (a second `task` event ends it). Only the running author execution is read, newest events
|
|
83
|
+
* first and capped, so it never grows with the table. The lane is a hint: `lane` in an event's payload, else `--lane-N` in a call's argument.
|
|
84
|
+
*/
|
|
85
|
+
function liveHints(db, epicId) {
|
|
86
|
+
const hints = new Map();
|
|
87
|
+
const running = db
|
|
88
|
+
.select({ executionId: executions.executionId })
|
|
89
|
+
.from(executions)
|
|
90
|
+
.where(and(eq(executions.epicId, epicId), eq(executions.stage, 'ship'), eq(executions.status, 'running')))
|
|
91
|
+
.orderBy(desc(sql `executions.rowid`))
|
|
92
|
+
.get();
|
|
93
|
+
if (running === undefined)
|
|
94
|
+
return hints;
|
|
95
|
+
const newest = (kind) => db.select().from(executionEvents).where(and(eq(executionEvents.executionId, running.executionId), eq(executionEvents.kind, kind))).orderBy(desc(executionEvents.id)).limit(MAX_LIVE_EVENTS).all();
|
|
96
|
+
const calls = newest('tool_start');
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const task of newest('task')) {
|
|
99
|
+
if (task.agentId === null || seen.has(task.agentId))
|
|
100
|
+
continue;
|
|
101
|
+
seen.add(task.agentId);
|
|
102
|
+
const slice = task.payload?.slice;
|
|
103
|
+
if (task.status !== null || slice === undefined || hints.has(slice))
|
|
104
|
+
continue;
|
|
105
|
+
const mine = calls.filter((call) => call.agentId === task.agentId);
|
|
106
|
+
const lane = mine.map((call) => call.payload?.lane ?? Number(/--lane-(\d+)/.exec(call.arg ?? '')?.[1] ?? NaN)).find((n) => Number.isNaN(n) === false);
|
|
107
|
+
const call = mine[0];
|
|
108
|
+
hints.set(slice, { lane: lane ?? null, activity: call === undefined ? null : [call.tool, call.arg].filter((part) => part !== null).join(' ').replace(/\s+/g, ' ').slice(0, MAX_ACTIVITY) });
|
|
109
|
+
}
|
|
110
|
+
return hints;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The stage timeline, review rounds, worktrees and branch of an epic; null for one the engine does not know.
|
|
114
|
+
* Built from the executions and the stored results, so it also reads an epic that has finished. A round is a review that
|
|
115
|
+
* left a result: technical retries leave none and make no round. An epic put back in the queue starts new rounds from 1
|
|
116
|
+
* after the old ones, which stay in the list. `slices` is the cache of GitHub the poll keeps (ADR 0008); `pr` is the block the poll cached from GitHub, null while the epic has no pull request, and `threads` the pull request's review threads cached with it.
|
|
117
|
+
*/
|
|
118
|
+
export function epicOf(db, repo, issue) {
|
|
119
|
+
const epic = db.select().from(epics).where(and(eq(epics.repo, repo), eq(epics.issue, issue))).get();
|
|
120
|
+
if (epic === undefined)
|
|
121
|
+
return null;
|
|
122
|
+
const runs = db
|
|
123
|
+
.select({ run: executions, result: callerResults.result })
|
|
124
|
+
.from(executions)
|
|
125
|
+
.leftJoin(callerResults, eq(callerResults.executionId, executions.executionId))
|
|
126
|
+
.where(eq(executions.epicId, epic.id))
|
|
127
|
+
.orderBy(sql `executions.rowid`)
|
|
128
|
+
.all();
|
|
129
|
+
// Rounds, attempt by attempt: a ship after a review or a fix is an epic that started again.
|
|
130
|
+
let attempt = 0;
|
|
131
|
+
let worked = false; // a review or a fix ran in this attempt
|
|
132
|
+
let open = null; // the previous round's open blockers; null before the first round
|
|
133
|
+
const found = [];
|
|
134
|
+
for (const { run, result } of runs) {
|
|
135
|
+
if (run.stage === 'ship') {
|
|
136
|
+
if (worked) {
|
|
137
|
+
attempt++;
|
|
138
|
+
worked = false;
|
|
139
|
+
open = null;
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
worked = true;
|
|
144
|
+
const last = found.at(-1);
|
|
145
|
+
if (run.stage === 'fix') {
|
|
146
|
+
if (last === undefined || last.attempt !== attempt)
|
|
147
|
+
continue;
|
|
148
|
+
last.fixed = true;
|
|
149
|
+
if (result === null)
|
|
150
|
+
continue; // a retry or one still running
|
|
151
|
+
const parsed = JSON.parse(result);
|
|
152
|
+
last.round.fixMs = run.finishedAt === null ? null : run.finishedAt - run.startedAt;
|
|
153
|
+
last.round.fixedSha = parsed.outcome === 'completed' ? parsed.pr.headSha : null;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (result === null)
|
|
157
|
+
continue;
|
|
158
|
+
const parsed = JSON.parse(result);
|
|
159
|
+
if (parsed.outcome === 'needs_human')
|
|
160
|
+
continue; // the reviewer stopped: no verdict to show
|
|
161
|
+
const toFound = ({ id, title, url }) => ({ id: id ?? null, title: title.slice(0, MAX_TEXT), url: url !== undefined && url.length <= MAX_URL ? url : null });
|
|
162
|
+
const blockers = parsed.outcome === 'changes_requested' ? parsed.blockers.map(toFound) : [];
|
|
163
|
+
const observations = parsed.outcome === 'completed' ? (parsed.observations ?? []).slice(0, MAX_OBSERVATIONS).map(toFound) : [];
|
|
164
|
+
found.push({
|
|
165
|
+
attempt,
|
|
166
|
+
fixed: false,
|
|
167
|
+
round: {
|
|
168
|
+
round: run.round ?? (found.filter((f) => f.attempt === attempt).length + 1),
|
|
169
|
+
reviewer: agentOf(run, epic.config),
|
|
170
|
+
verdict: parsed.outcome === 'completed' ? 'approved' : 'changes_requested',
|
|
171
|
+
blockers: marked(blockers, open, parsed.resolved ?? []),
|
|
172
|
+
observations,
|
|
173
|
+
reviewMs: run.finishedAt === null ? null : run.finishedAt - run.startedAt,
|
|
174
|
+
fixMs: null,
|
|
175
|
+
reviewedSha: parsed.pr.headSha,
|
|
176
|
+
fixedSha: null,
|
|
177
|
+
commits: [],
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
open = blockers;
|
|
181
|
+
}
|
|
182
|
+
const hitLimit = epic.status === 'needs_human' && epic.needsHumanReason?.startsWith('review limit') === true;
|
|
183
|
+
const rounds = found.slice(-MAX_ROUNDS).map(({ round, attempt: of, fixed }) => {
|
|
184
|
+
// Nothing answered it and nothing follows: the epic stopped there for the limit.
|
|
185
|
+
if (round.verdict === 'changes_requested' && fixed === false && (of < attempt || hitLimit))
|
|
186
|
+
round.verdict = 'limit_reached';
|
|
187
|
+
if (round.reviewedSha !== null && round.fixedSha !== null)
|
|
188
|
+
round.commits = commitsBetween(epic.workspace, round.reviewedSha, round.fixedSha);
|
|
189
|
+
return round;
|
|
190
|
+
});
|
|
191
|
+
// The stages of the current attempt: queued, then each contiguous run of a stage, then ready.
|
|
192
|
+
const stages = [{ stage: 'queued', startedAt: epic.queuedAt, endedAt: epic.startedAt }];
|
|
193
|
+
if (epic.startedAt !== null) {
|
|
194
|
+
const startedAt = epic.startedAt;
|
|
195
|
+
const segments = [];
|
|
196
|
+
for (const { run } of runs) {
|
|
197
|
+
if (run.startedAt >= startedAt && segments.at(-1)?.stage !== run.stage)
|
|
198
|
+
segments.push({ stage: run.stage, startedAt: segments.length === 0 ? startedAt : run.startedAt });
|
|
199
|
+
}
|
|
200
|
+
// reserved, the clone still being prepared: no execution yet
|
|
201
|
+
if (segments.length === 0 && epic.stage !== null)
|
|
202
|
+
segments.push({ stage: epic.stage, startedAt });
|
|
203
|
+
const ended = epic.status === 'running' ? null : epic.finishedAt;
|
|
204
|
+
stages.push(...segments.map((segment, i) => ({ ...segment, endedAt: segments[i + 1]?.startedAt ?? ended })));
|
|
205
|
+
if (epic.status === 'ready_for_test' && epic.finishedAt !== null)
|
|
206
|
+
stages.push({ stage: 'ready', startedAt: epic.finishedAt, endedAt: null });
|
|
207
|
+
}
|
|
208
|
+
const reviewer = runs.findLast(({ run }) => run.stage === 'review' && run.workspace !== null)?.run.workspace ?? null;
|
|
209
|
+
const worktrees = [
|
|
210
|
+
...(epic.workspace === null ? [] : [{ kind: 'epic', lane: null, path: epic.workspace }]),
|
|
211
|
+
...(reviewer === null ? [] : [{ kind: 'reviewer', lane: null, path: reviewer }]),
|
|
212
|
+
];
|
|
213
|
+
// the row records the branch once ship is validated; before that the worktree's HEAD is the only place it is
|
|
214
|
+
const branch = epic.branch ?? (epic.workspace === null ? null : (git(epic.workspace, ['symbolic-ref', '--short', 'HEAD'])?.trim() || null));
|
|
215
|
+
const slices = db.select().from(epicSlices).where(eq(epicSlices.epicId, epic.id)).orderBy(epicSlices.number).all();
|
|
216
|
+
const threads = db.select().from(epicReviewThreads).where(eq(epicReviewThreads.epicId, epic.id)).orderBy(epicReviewThreads.position).all();
|
|
217
|
+
const hints = liveHints(db, epic.id);
|
|
218
|
+
return { stages, rounds, slices: slices.map(({ number, title, state, blockedBy, prNumber, prState, prMergedAt, closedAt, readAt }) => ({ number, title, state, blockedBy, prNumber, prState, prMergedAt, closedAt, readAt, lane: null, activity: null, ...(state === 'in_progress' && hints.get(number)) })), slicesTruncated: slices.some((s) => s.truncated), threads: threads.map(({ epicId, position, readAt, truncated, ...thread }) => thread), threadsTruncated: threads.some((t) => t.truncated), worktrees, branch, options: optionsOf(epic), pr: epic.pr };
|
|
219
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { and, asc, desc, eq, gt } from 'drizzle-orm';
|
|
2
|
+
import { executionEvents } from '../storage/events.js';
|
|
3
|
+
import { executions } from '../storage/executions.js';
|
|
4
|
+
import { epics } from '../storage/schema.js';
|
|
5
|
+
import { agentOf } from './sessions.js';
|
|
6
|
+
// Only what ExecEvent names leaves: the slice and lane hints in the payload are for the engine.
|
|
7
|
+
const wire = (row) => ({
|
|
8
|
+
id: row.id,
|
|
9
|
+
at: row.at,
|
|
10
|
+
kind: row.kind,
|
|
11
|
+
...(row.payload?.text !== undefined && { text: row.payload.text }),
|
|
12
|
+
...(row.callId !== null && { callId: row.callId }),
|
|
13
|
+
...(row.agentId !== null && { agentId: row.agentId }),
|
|
14
|
+
...(row.tool !== null && { tool: row.tool }),
|
|
15
|
+
...(row.arg !== null && { arg: row.arg }),
|
|
16
|
+
...(row.summary !== null && { summary: row.summary }),
|
|
17
|
+
...(row.status !== null && { status: row.status }),
|
|
18
|
+
...(row.durationMs !== null && { durationMs: row.durationMs }),
|
|
19
|
+
...(row.payload?.description !== undefined && { description: row.payload.description }),
|
|
20
|
+
...(row.payload?.output !== undefined && { output: row.payload.output }),
|
|
21
|
+
...(row.payload?.diff !== undefined && { diff: row.payload.diff }),
|
|
22
|
+
});
|
|
23
|
+
// The harnesses that have an events parser.
|
|
24
|
+
const structuredHarnesses = ['claude', 'codex', 'agy'];
|
|
25
|
+
/**
|
|
26
|
+
* The events of an execution after the cursor, oldest first, at most `limit`; null for an execution the engine does not know.
|
|
27
|
+
* A harness with no parser yet (a future one; today all three have one) answers `structured: false` and no events.
|
|
28
|
+
*/
|
|
29
|
+
export function eventsOf(db, executionId, after, limit) {
|
|
30
|
+
const row = db.select({ execution: executions, config: epics.config }).from(executions).innerJoin(epics, eq(epics.id, executions.epicId)).where(eq(executions.executionId, executionId)).get();
|
|
31
|
+
if (row === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
const running = row.execution.status === 'running';
|
|
34
|
+
if (!structuredHarnesses.includes(agentOf(row.execution, row.config).harness))
|
|
35
|
+
return { events: [], next: after, running, structured: false };
|
|
36
|
+
const events = db.select().from(executionEvents).where(and(eq(executionEvents.executionId, executionId), gt(executionEvents.id, after))).orderBy(asc(executionEvents.id)).limit(limit).all().map(wire);
|
|
37
|
+
return { events, next: events.at(-1)?.id ?? after, running, structured: true };
|
|
38
|
+
}
|
|
39
|
+
// A line of the Overview: what the agent is doing now, in the words of its newest event.
|
|
40
|
+
const describe = (row) => {
|
|
41
|
+
const call = [row.tool, row.arg].filter((part) => part !== null).join(' ');
|
|
42
|
+
return (row.kind === 'tool_start' ? call : row.kind === 'tool_end' ? [row.tool, row.summary].filter((part) => part !== null).join(' ') : (row.payload?.text ?? '')).slice(0, 200);
|
|
43
|
+
};
|
|
44
|
+
/** The newest event of an execution for the snapshot; null while it has none. */
|
|
45
|
+
export function lastEventOf(db, executionId) {
|
|
46
|
+
const row = db.select().from(executionEvents).where(eq(executionEvents.executionId, executionId)).orderBy(desc(executionEvents.id)).limit(1).get();
|
|
47
|
+
return row === undefined ? null : { kind: row.kind, text: describe(row), at: row.at };
|
|
48
|
+
}
|