@alecsibilia/luca 14.0.0-alpha.19 → 14.0.0-alpha.20
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/CHANGELOG.md +4 -0
- package/board/package.json +1 -1
- package/board/server/board-vocabulary-review.ts +5 -0
- package/board/server/luca-version.ts +1 -1
- package/board/server/record-texts.ts +4 -0
- package/board/server/reduce-board-create.ts +1 -0
- package/board/server/reduce-board-needs-you.ts +11 -5
- package/board/server/reduce-board-reasons.ts +1 -1
- package/board/shared/board-state.ts +9 -2
- package/board/shared/reply-actions.ts +8 -2
- package/engine/agents/agent-launcher.ts +7 -2
- package/engine/agents/claude-launcher.ts +1 -0
- package/engine/agents/claude-messages.ts +2 -0
- package/engine/agents/common-instructions.ts +24 -1
- package/engine/agents/open-session.ts +1 -0
- package/engine/agents/role-instructions.ts +16 -3
- package/engine/agents/scripted-launcher.ts +4 -0
- package/engine/core/agent-guards-helpers.ts +8 -1
- package/engine/core/agent-turn.ts +52 -11
- package/engine/core/decide-final-fix.ts +9 -3
- package/engine/core/decide-final-review.ts +27 -6
- package/engine/core/decide-run-budget.ts +61 -12
- package/engine/core/decide-stuck.ts +52 -6
- package/engine/core/engine-chores.ts +24 -7
- package/engine/core/execute-final-review.ts +1 -0
- package/engine/core/execute-repo-wait.ts +28 -12
- package/engine/core/execute-stuck.ts +3 -0
- package/engine/core/execute.ts +1 -1
- package/engine/core/final-review-action.ts +2 -1
- package/engine/core/final-review-text.ts +32 -4
- package/engine/core/pull-request-text.ts +63 -6
- package/engine/core/repo-wait-text.ts +18 -5
- package/engine/core/run-stuck-text.ts +1 -1
- package/engine/core/stuck-text.ts +4 -0
- package/engine/gates/shell-check.ts +5 -1
- package/engine/gates/test-runner.ts +1 -1
- package/engine/guards/after-turn-check.ts +31 -5
- package/engine/guards/worktree-git.ts +208 -0
- package/engine/guards/worktree-snapshot.ts +412 -0
- package/engine/guards/worktree-state.ts +28 -825
- package/engine/guards/worktree-undo.ts +260 -0
- package/engine/intake/criteria-checks.ts +97 -0
- package/engine/intake/intake-checks.ts +10 -0
- package/engine/journal/agent-entries.ts +13 -3
- package/engine/journal/agent-turn-replay.ts +13 -1
- package/engine/journal/cross-ticket-replay.ts +47 -2
- package/engine/journal/final-review-entries.ts +10 -4
- package/engine/journal/final-review-replay.ts +22 -3
- package/engine/journal/final-review-round.ts +61 -1
- package/engine/journal/final-review-state.ts +15 -0
- package/engine/journal/journal-entry-schema.ts +2 -0
- package/engine/journal/journal-kinds.ts +1 -0
- package/engine/journal/journal-record-schema.ts +2 -0
- package/engine/journal/replay.ts +20 -5
- package/engine/journal/reply-replay.ts +12 -6
- package/engine/journal/repo-wait-entries.ts +11 -0
- package/engine/journal/run-state.ts +13 -5
- package/engine/journal/stuck-entries.ts +10 -3
- package/engine/journal/ticket-progress.ts +6 -0
- package/engine/journal/ticket-replay.ts +12 -1
- package/engine/shell/run-command.ts +146 -18
- package/engine/testing/agent-turn-fixtures.ts +8 -0
- package/engine/testing/final-review-fixtures.ts +11 -0
- package/engine/testing/final-review-steps.ts +143 -0
- package/engine/testing/practice-repo.ts +6 -4
- package/engine/testing/run-budget-fixtures.ts +165 -0
- package/package.json +1 -1
- package/skills/luca-unstick/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
package/board/package.json
CHANGED
|
@@ -14,6 +14,11 @@ export const REVIEW_VOCABULARY = {
|
|
|
14
14
|
/** `ship` is only for a stuck final review. */
|
|
15
15
|
word: z.enum(['retry', 'skip', 'stop', 'ship']),
|
|
16
16
|
ticket: z.number().int().nullable().optional(),
|
|
17
|
+
/**
|
|
18
|
+
* The tokens a `retry` of the run stuck on its budget added (#543);
|
|
19
|
+
* absent before top-ups, when it added one whole budget.
|
|
20
|
+
*/
|
|
21
|
+
top_up: z.number().int().positive().optional().catch(undefined),
|
|
17
22
|
}),
|
|
18
23
|
/** A reply the engine couldn't use; it answered why on the spec issue. */
|
|
19
24
|
reply_ignored: z.looseObject({
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The Luca version of this board, stamped when the package was packed. */
|
|
2
|
-
export const LUCA_VERSION: string = "14.0.0-alpha.
|
|
2
|
+
export const LUCA_VERSION: string = "14.0.0-alpha.20"
|
|
@@ -53,6 +53,10 @@ export const ignoredText = ({
|
|
|
53
53
|
return 'nothing is stuck right now'
|
|
54
54
|
case 'ship_needs_final_review':
|
|
55
55
|
return '`ship` is only for a stuck final review'
|
|
56
|
+
case 'amount_only_for_run_budget':
|
|
57
|
+
return 'an amount is only for a run stuck on its budget'
|
|
58
|
+
case 'bad_amount':
|
|
59
|
+
return 'the amount is not a whole number above zero'
|
|
56
60
|
default:
|
|
57
61
|
return reason.replaceAll('_', ' ')
|
|
58
62
|
}
|
|
@@ -75,6 +75,7 @@ export const createBoardState = ({
|
|
|
75
75
|
run_tokens: 0,
|
|
76
76
|
run_budget_tokens: DEFAULT_RUN_BUDGET_TOKENS,
|
|
77
77
|
run_budget_each: DEFAULT_RUN_BUDGET_TOKENS,
|
|
78
|
+
run_stuck_reason: null,
|
|
78
79
|
confidence_bar: DEFAULT_CONFIDENCE_BAR,
|
|
79
80
|
limit_wait: null,
|
|
80
81
|
usage_line_wait: null,
|
|
@@ -151,14 +151,20 @@ const replyReceived = ({
|
|
|
151
151
|
key: ticketKey({ ticket }),
|
|
152
152
|
})
|
|
153
153
|
}
|
|
154
|
-
// A bare `retry` while the run is stuck on its budget adds
|
|
155
|
-
//
|
|
154
|
+
// A bare `retry` while the run is stuck on its budget adds its
|
|
155
|
+
// top-up (one whole budget for a retry journaled with none,
|
|
156
|
+
// #543); stuck for another reason, no budget. The final review
|
|
157
|
+
// is left as it is.
|
|
156
158
|
if (state.needs_you.some((item) => item.key === RUN_KEY)) {
|
|
159
|
+
const top_up =
|
|
160
|
+
state.run_stuck_reason === 'run_budget'
|
|
161
|
+
? (record.content.top_up ?? state.run_budget_each)
|
|
162
|
+
: 0
|
|
157
163
|
return resolveNeedsYou({
|
|
158
164
|
state: {
|
|
159
165
|
...state,
|
|
160
|
-
run_budget_tokens:
|
|
161
|
-
|
|
166
|
+
run_budget_tokens: state.run_budget_tokens + top_up,
|
|
167
|
+
run_stuck_reason: null,
|
|
162
168
|
},
|
|
163
169
|
key: RUN_KEY,
|
|
164
170
|
})
|
|
@@ -245,7 +251,7 @@ export const applyNeedsYouKind = ({
|
|
|
245
251
|
}
|
|
246
252
|
case 'run_stuck':
|
|
247
253
|
return addNeedsYou({
|
|
248
|
-
state,
|
|
254
|
+
state: { ...state, run_stuck_reason: record.content.reason },
|
|
249
255
|
item: {
|
|
250
256
|
key: RUN_KEY,
|
|
251
257
|
ticket: null,
|
|
@@ -49,7 +49,7 @@ export const finalReasonText = ({ reason }: { reason: string }): string =>
|
|
|
49
49
|
/** Why the whole run is stuck (`run_stuck`'s reason), in words. */
|
|
50
50
|
const RUN_STUCK_REASONS: Record<string, string> = {
|
|
51
51
|
run_budget:
|
|
52
|
-
'The run used up its run budget of tokens. `retry` adds
|
|
52
|
+
'The run used up its run budget of tokens. `retry` adds half a budget more, and `retry +3M` the amount named.',
|
|
53
53
|
pull_request_failed:
|
|
54
54
|
"The run's pull request didn't open. `retry` tries again, reusing a PR already open from the run branch.",
|
|
55
55
|
}
|
|
@@ -74,12 +74,19 @@ export const BoardStateSchema = z.object({
|
|
|
74
74
|
run_tokens: z.number().min(0).default(0),
|
|
75
75
|
/**
|
|
76
76
|
* The tokens the run may use before it is stuck (#435): one full run
|
|
77
|
-
* budget, plus
|
|
78
|
-
*
|
|
77
|
+
* budget, plus the top-up of each `retry` the owner gave the run stuck
|
|
78
|
+
* on its budget (#543; one whole budget for a retry journaled with no
|
|
79
|
+
* top-up). Defaulted, so an older board state still parses.
|
|
79
80
|
*/
|
|
80
81
|
run_budget_tokens: z.number().min(0).default(DEFAULT_RUN_BUDGET_TOKENS),
|
|
81
82
|
/** One full run budget: the engine config's, else the default. */
|
|
82
83
|
run_budget_each: z.number().min(0).default(DEFAULT_RUN_BUDGET_TOKENS),
|
|
84
|
+
/**
|
|
85
|
+
* Why the whole run is stuck (`run_stuck`'s reason), until the owner's
|
|
86
|
+
* reply; `null` while it isn't. Defaulted, so an older board state
|
|
87
|
+
* still parses.
|
|
88
|
+
*/
|
|
89
|
+
run_stuck_reason: z.string().nullable().default(null),
|
|
83
90
|
/**
|
|
84
91
|
* The engine config's `models.confidence_bar` (#630): the decision
|
|
85
92
|
* model is sharp at or over it. Defaulted, so an older board state
|
|
@@ -54,14 +54,20 @@ export type ReplyParts = {
|
|
|
54
54
|
|
|
55
55
|
const REPLY_PATTERN = /^(retry|skip|stop|ship)(?: #(\d+))?$/
|
|
56
56
|
|
|
57
|
+
/** `retry` with an amount for the run stuck on its budget (#543). */
|
|
58
|
+
const AMOUNT_REPLY_PATTERN = /^retry \+\S+$/
|
|
59
|
+
|
|
57
60
|
/**
|
|
58
|
-
* Splits one of `allowedReplies
|
|
59
|
-
* with the engine's `reply_received`.
|
|
61
|
+
* Splits one of `allowedReplies`, or a `retry +<amount>` the owner typed,
|
|
62
|
+
* into its word and ticket, to match it with the engine's `reply_received`.
|
|
63
|
+
* `null` for anything else.
|
|
60
64
|
*
|
|
61
65
|
* @example
|
|
62
66
|
* replyParts({ reply: 'retry #12' }) // { word: 'retry', ticket: 12 }
|
|
67
|
+
* replyParts({ reply: 'retry +3M' }) // { word: 'retry', ticket: null }
|
|
63
68
|
*/
|
|
64
69
|
export const replyParts = ({ reply }: { reply: string }): ReplyParts | null => {
|
|
70
|
+
if (AMOUNT_REPLY_PATTERN.test(reply)) return { word: 'retry', ticket: null }
|
|
65
71
|
const match = REPLY_PATTERN.exec(reply)
|
|
66
72
|
const word = z.enum(['retry', 'skip', 'stop', 'ship']).safeParse(match?.[1])
|
|
67
73
|
if (!match || !word.success) return null
|
|
@@ -100,7 +100,10 @@ export type LauncherFailure = z.infer<typeof LauncherFailureSchema>
|
|
|
100
100
|
* the same way; a success with no structured output is a failed try.
|
|
101
101
|
* `session_id` names the session the turn ran in, so the engine can send that
|
|
102
102
|
* same agent a follow-up in a fix loop. `session` is the launcher's summary
|
|
103
|
-
* of the model session, for the journal.
|
|
103
|
+
* of the model session, for the journal. `session_gone` marks a follow-up
|
|
104
|
+
* that found its session closed or unknown (#521): no agent ran, and the
|
|
105
|
+
* engine hands the follow-up's message to a fresh agent. `out_of_turns`
|
|
106
|
+
* marks a turn that reached its role's turn limit (#543).
|
|
104
107
|
*/
|
|
105
108
|
export type AgentTurn =
|
|
106
109
|
| {
|
|
@@ -115,6 +118,8 @@ export type AgentTurn =
|
|
|
115
118
|
error: string
|
|
116
119
|
session_id?: string
|
|
117
120
|
session?: AgentSession
|
|
121
|
+
session_gone?: boolean
|
|
122
|
+
out_of_turns?: boolean
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
@@ -179,7 +184,7 @@ export type AgentLauncher = {
|
|
|
179
184
|
* as a failed gate's output in a fix loop, and returns the session's new
|
|
180
185
|
* result. The agent keeps everything it knew from its earlier turns, the
|
|
181
186
|
* guards it was launched with, and its model. A session the launcher doesn't know,
|
|
182
|
-
* or one that has closed, is an engine failure
|
|
187
|
+
* or one that has closed, is an engine failure with `session_gone`.
|
|
183
188
|
*
|
|
184
189
|
* @example
|
|
185
190
|
* const turn = await launcher.followUp({ session_id, role: 'implementer', ticket: 11, message: 'lint failed: ...', cwd, config })
|
|
@@ -103,6 +103,8 @@ export type Failed = {
|
|
|
103
103
|
ok: false
|
|
104
104
|
failure: 'agent' | 'engine' | 'stop' | 'plan'
|
|
105
105
|
error: string
|
|
106
|
+
/** The turn reached its role's turn limit (#543). */
|
|
107
|
+
out_of_turns?: boolean
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
export type Outcome = { ok: true; structured_output: unknown } | Failed
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The instruction sections more than one role gets: the shell rules, agent
|
|
3
|
-
* messages, helpers, the
|
|
3
|
+
* messages, helpers, the sandbox, the repo's rule files, the rules for every
|
|
4
|
+
* agent, and fixing review findings.
|
|
4
5
|
*/
|
|
5
6
|
import type { AgentRole } from './role-results'
|
|
6
7
|
|
|
@@ -72,6 +73,28 @@ export const COMMON = `## Rules for every agent
|
|
|
72
73
|
- "run_notes": at most 3 short facts about this repo that would help later agents in this run. Can be empty.
|
|
73
74
|
- Finish by giving your structured result. A turn without one counts as failed.`
|
|
74
75
|
|
|
76
|
+
/** What every role's sandbox blocks, and that the gates run outside it (#540). */
|
|
77
|
+
export const SANDBOX = `## Your sandbox
|
|
78
|
+
- The engine runs the gates (tests, types, lint) outside your sandbox and gives you their results.
|
|
79
|
+
- The sandbox blocks the network and local ports, writes outside your worktree, git internals, \`node_modules\`, lockfiles, test setup files, test files for roles that may not edit them, and secret files.
|
|
80
|
+
- A command that fails only inside your sandbox isn't yours to fix or work around. Rely on the engine's gate results, and say so.`
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The writers' list of the repo's rule files (#646), named, not pasted in,
|
|
84
|
+
* or nothing when the repo names none.
|
|
85
|
+
*/
|
|
86
|
+
export const repoRules = ({ config }: { config: EngineConfig }): string[] =>
|
|
87
|
+
config.rule_files.length === 0
|
|
88
|
+
? []
|
|
89
|
+
: [
|
|
90
|
+
[
|
|
91
|
+
'## Repo rules',
|
|
92
|
+
"This repo's rules are in these files:",
|
|
93
|
+
...config.rule_files.map((path) => `- \`${path}\``),
|
|
94
|
+
'Read them before you start, and follow them.',
|
|
95
|
+
].join('\n'),
|
|
96
|
+
]
|
|
97
|
+
|
|
75
98
|
export const REVIEW_FIXER = `## Fixing review findings
|
|
76
99
|
Sometimes you are sent a ticket review's findings to fix, after the ticket's work is committed, or the final review's findings to fix on the whole run branch, after every ticket joined it. Then:
|
|
77
100
|
- Fix each finding you were sent, keeping to your role's rules.
|
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
* Each role's instructions, put together from the role's task section and
|
|
3
3
|
* the shared sections.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
COMMON,
|
|
7
|
+
HELPERS,
|
|
8
|
+
MESSAGES,
|
|
9
|
+
repoRules,
|
|
10
|
+
SANDBOX,
|
|
11
|
+
shellRules,
|
|
12
|
+
} from './common-instructions'
|
|
6
13
|
import { LEARNER, LEARNER_RULES } from './learner-instructions'
|
|
7
14
|
import { LENS, REVIEWER } from './reviewer-instructions'
|
|
8
15
|
import { lensOf, type AgentRole } from './role-results'
|
|
@@ -21,7 +28,9 @@ import { canMessage } from '../messages/agent-messages'
|
|
|
21
28
|
* reviewers aren't. Each final review lens gets its own focus; the
|
|
22
29
|
* integration lens also gives the PR's Summary picture and Merge Danger
|
|
23
30
|
* (#513). The learner (#370) gets its own task and rules: no shell,
|
|
24
|
-
* nothing written. Every role is told about its read-only helpers (#627)
|
|
31
|
+
* nothing written. Every role is told about its read-only helpers (#627),
|
|
32
|
+
* and what its sandbox blocks (#540). The writers, as final review fixers
|
|
33
|
+
* too, are told where the repo's rule files are (#646).
|
|
25
34
|
*
|
|
26
35
|
* @example
|
|
27
36
|
* const append = roleInstructions({ role: 'implementer', may_edit_tests: false, config })
|
|
@@ -43,7 +52,7 @@ export const roleInstructions = ({
|
|
|
43
52
|
}): string => {
|
|
44
53
|
// The learner has no shell and no agent messages, and its rules differ.
|
|
45
54
|
if (role === 'learner')
|
|
46
|
-
return [LEARNER, HELPERS, LEARNER_RULES].join('\n\n')
|
|
55
|
+
return [LEARNER, HELPERS, SANDBOX, LEARNER_RULES].join('\n\n')
|
|
47
56
|
const lens = lensOf({ role })
|
|
48
57
|
const task =
|
|
49
58
|
lens !== null
|
|
@@ -55,9 +64,13 @@ export const roleInstructions = ({
|
|
|
55
64
|
: REVIEWER
|
|
56
65
|
return [
|
|
57
66
|
task,
|
|
67
|
+
...(role === 'test-writer' || role === 'implementer'
|
|
68
|
+
? repoRules({ config })
|
|
69
|
+
: []),
|
|
58
70
|
shellRules({ role, config }),
|
|
59
71
|
...((messaging ?? canMessage({ role })) ? [MESSAGES] : []),
|
|
60
72
|
HELPERS,
|
|
73
|
+
SANDBOX,
|
|
61
74
|
COMMON,
|
|
62
75
|
].join('\n\n')
|
|
63
76
|
}
|
|
@@ -50,6 +50,8 @@ export type ScriptedTurn = {
|
|
|
50
50
|
result?: unknown
|
|
51
51
|
failure?: LauncherFailure
|
|
52
52
|
error?: string
|
|
53
|
+
/** With `failure`: the turn reached its role's turn limit (#543). */
|
|
54
|
+
out_of_turns?: boolean
|
|
53
55
|
/** A session summary for the engine to journal. */
|
|
54
56
|
session?: AgentSession
|
|
55
57
|
}
|
|
@@ -169,6 +171,7 @@ export const createScriptedLauncher = ({
|
|
|
169
171
|
failure: turn.failure,
|
|
170
172
|
session_id,
|
|
171
173
|
error: turn.error ?? `The scripted ${role} failed.`,
|
|
174
|
+
...(turn.out_of_turns === true ? { out_of_turns: true } : {}),
|
|
172
175
|
...session,
|
|
173
176
|
}
|
|
174
177
|
}
|
|
@@ -223,6 +226,7 @@ export const createScriptedLauncher = ({
|
|
|
223
226
|
ok: false,
|
|
224
227
|
failure: 'engine',
|
|
225
228
|
session_id,
|
|
229
|
+
session_gone: true,
|
|
226
230
|
error: `No scripted session ${session_id}.`,
|
|
227
231
|
}
|
|
228
232
|
}
|
|
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { afterAll, beforeAll } from 'bun:test'
|
|
6
|
+
import omit from 'lodash/omit'
|
|
6
7
|
|
|
7
8
|
import type { ScriptedTurn } from '../agents/scripted-launcher'
|
|
8
9
|
import type { JournalRecord } from '../journal/journal-record'
|
|
@@ -18,9 +19,15 @@ import { createPracticeRepo } from '../testing/practice-repo'
|
|
|
18
19
|
export type Practice = Awaited<ReturnType<typeof createPracticeRepo>>
|
|
19
20
|
export type Ran = Awaited<ReturnType<Practice['run']>>
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Each failed turn's content, without `session_gone` (#521) or `out_of_turns`
|
|
24
|
+
* (#543): no guard test follows up a gone session or reaches a turn limit.
|
|
25
|
+
*/
|
|
21
26
|
export const failures = (records: JournalRecord[]) =>
|
|
22
27
|
records.flatMap((record) =>
|
|
23
|
-
record.kind === 'agent_failed'
|
|
28
|
+
record.kind === 'agent_failed'
|
|
29
|
+
? [omit(record.content, 'session_gone', 'out_of_turns')]
|
|
30
|
+
: []
|
|
24
31
|
)
|
|
25
32
|
|
|
26
33
|
export const commits = (records: JournalRecord[]) =>
|
|
@@ -31,6 +31,8 @@ const failTurn = ({
|
|
|
31
31
|
error,
|
|
32
32
|
failure,
|
|
33
33
|
session_id,
|
|
34
|
+
session_gone,
|
|
35
|
+
out_of_turns,
|
|
34
36
|
}: {
|
|
35
37
|
context: BuildContext
|
|
36
38
|
ticket: number | null
|
|
@@ -38,12 +40,21 @@ const failTurn = ({
|
|
|
38
40
|
error: string
|
|
39
41
|
failure: AgentFailure
|
|
40
42
|
session_id: string | null
|
|
43
|
+
session_gone: boolean
|
|
44
|
+
out_of_turns: boolean
|
|
41
45
|
}) => {
|
|
42
46
|
context.journal.append({
|
|
43
47
|
kind: 'agent_failed',
|
|
44
48
|
ticket,
|
|
45
49
|
role,
|
|
46
|
-
content: {
|
|
50
|
+
content: {
|
|
51
|
+
role,
|
|
52
|
+
error,
|
|
53
|
+
failure,
|
|
54
|
+
session_id,
|
|
55
|
+
...(session_gone ? { session_gone } : {}),
|
|
56
|
+
...(out_of_turns ? { out_of_turns } : {}),
|
|
57
|
+
},
|
|
47
58
|
})
|
|
48
59
|
}
|
|
49
60
|
|
|
@@ -144,27 +155,54 @@ export const runTurn = async ({
|
|
|
144
155
|
throw new Error(`Run stopped: ${turn.error}`)
|
|
145
156
|
}
|
|
146
157
|
const session_id = turn.session_id ?? null
|
|
147
|
-
const fail = (
|
|
148
|
-
|
|
158
|
+
const fail = ({
|
|
159
|
+
failure,
|
|
160
|
+
error,
|
|
161
|
+
session_gone = false,
|
|
162
|
+
out_of_turns = false,
|
|
163
|
+
}: {
|
|
164
|
+
failure: AgentFailure
|
|
165
|
+
error: string
|
|
166
|
+
session_gone?: boolean
|
|
167
|
+
out_of_turns?: boolean
|
|
168
|
+
}) => {
|
|
169
|
+
failTurn({
|
|
170
|
+
context,
|
|
171
|
+
ticket,
|
|
172
|
+
role,
|
|
173
|
+
error,
|
|
174
|
+
failure,
|
|
175
|
+
session_id,
|
|
176
|
+
session_gone,
|
|
177
|
+
out_of_turns,
|
|
178
|
+
})
|
|
149
179
|
return null
|
|
150
180
|
}
|
|
151
181
|
if (violations.length > 0) {
|
|
152
|
-
return fail(
|
|
153
|
-
'guard',
|
|
154
|
-
describeViolations({ role: guard_role, violations })
|
|
155
|
-
)
|
|
182
|
+
return fail({
|
|
183
|
+
failure: 'guard',
|
|
184
|
+
error: describeViolations({ role: guard_role, violations }),
|
|
185
|
+
})
|
|
156
186
|
}
|
|
157
187
|
if (!turn.ok) {
|
|
158
|
-
return fail(
|
|
188
|
+
return fail({
|
|
189
|
+
failure: turn.failure === 'engine' ? 'engine' : 'agent',
|
|
190
|
+
error: turn.error,
|
|
191
|
+
session_gone: turn.session_gone === true,
|
|
192
|
+
out_of_turns: turn.out_of_turns === true,
|
|
193
|
+
})
|
|
159
194
|
}
|
|
160
195
|
if (
|
|
161
196
|
turn.structured_output === undefined ||
|
|
162
197
|
turn.structured_output === null
|
|
163
198
|
) {
|
|
164
|
-
return fail(
|
|
199
|
+
return fail({
|
|
200
|
+
failure: 'result',
|
|
201
|
+
error: `The ${role} finished with no structured output.`,
|
|
202
|
+
})
|
|
165
203
|
}
|
|
166
204
|
const checked = parseRoleResult({ role, output: turn.structured_output })
|
|
167
|
-
if (!checked.ok) return fail('result', checked.error)
|
|
205
|
+
if (!checked.ok) return fail({ failure: 'result', error: checked.error })
|
|
168
206
|
// The chores the writer asked for (#618), before any check or commit.
|
|
169
207
|
const problems = await doChores({
|
|
170
208
|
context,
|
|
@@ -174,7 +212,10 @@ export const runTurn = async ({
|
|
|
174
212
|
guard: { role: guard_role, may_edit_tests },
|
|
175
213
|
})
|
|
176
214
|
if (problems.length > 0) {
|
|
177
|
-
return fail(
|
|
215
|
+
return fail({
|
|
216
|
+
failure: 'chore',
|
|
217
|
+
error: problems.map((line) => `- ${line}`).join('\n'),
|
|
218
|
+
})
|
|
178
219
|
}
|
|
179
220
|
context.journal.append({
|
|
180
221
|
kind: 'agent_finished',
|
|
@@ -112,7 +112,8 @@ export const fixStep = (args: DecideArgs): FinalReviewAction | null => {
|
|
|
112
112
|
const { review, run_branch, snapshot } = args
|
|
113
113
|
const { fix } = review
|
|
114
114
|
if (fix === null) return null
|
|
115
|
-
|
|
115
|
+
// The fresh pass's one fix round runs past the cap (#548).
|
|
116
|
+
if (fix.round > MAX_FIX_ROUNDS && !review.fresh) {
|
|
116
117
|
return stuck({
|
|
117
118
|
reason: 'changes_requested',
|
|
118
119
|
detail: `The final review still asks for changes after ${MAX_FIX_ROUNDS} fix rounds:\n${openFinalFindingsText({ findings: fix.findings })}`,
|
|
@@ -164,14 +165,19 @@ export const fixStep = (args: DecideArgs): FinalReviewAction | null => {
|
|
|
164
165
|
})
|
|
165
166
|
}
|
|
166
167
|
const session_id = review.sessions.implementer
|
|
168
|
+
const message = gateFixMessage({ gates })
|
|
167
169
|
if (session_id === undefined) {
|
|
168
|
-
return launchFixer({
|
|
170
|
+
return launchFixer({
|
|
171
|
+
...args,
|
|
172
|
+
role: 'implementer',
|
|
173
|
+
follow_up: message,
|
|
174
|
+
})
|
|
169
175
|
}
|
|
170
176
|
return {
|
|
171
177
|
type: 'follow_up_final_fixer',
|
|
172
178
|
role: 'implementer',
|
|
173
179
|
session_id,
|
|
174
|
-
message
|
|
180
|
+
message,
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
if (review.commit === null) {
|
|
@@ -61,9 +61,14 @@ const lensStep = ({
|
|
|
61
61
|
* tests once every lens finished, and settles only once they ran; failing
|
|
62
62
|
* ones open the fix round (or join it). A fix round with no finding, opened only
|
|
63
63
|
* by them, is just its gates (which run the slow tests again) with their
|
|
64
|
-
* fix loop, the commit, and the push,
|
|
64
|
+
* fix loop, the commit, and the push, with no lens re-review.
|
|
65
65
|
* A review still asking for changes after `MAX_FIX_ROUNDS` fix
|
|
66
66
|
* rounds is stuck, and so is a failed fix loop, a bad test, or a leftover.
|
|
67
|
+
* Once a fix commit was pushed and no blocking finding is left, the fresh
|
|
68
|
+
* pass (#548) has all five lenses read the whole branch again, without
|
|
69
|
+
* earlier findings; its blocking findings get one fix round (past the round
|
|
70
|
+
* cap, too) and one re-review, and what is still open then tops the PR
|
|
71
|
+
* instead of getting the review stuck.
|
|
67
72
|
* Failed tries are capped per role like a ticket's. A stuck final review
|
|
68
73
|
* waits for a reply; `ship` opens the PR anyway. A final review step cut
|
|
69
74
|
* off by a crash `MAX_CRASHES` times in a row is stuck too.
|
|
@@ -117,15 +122,31 @@ export const decideFinalReview = (args: DecideArgs): FinalReviewDecision => {
|
|
|
117
122
|
if (review.slow_due && review.slow_tests === null) {
|
|
118
123
|
return working([{ type: 'run_final_slow_tests' }])
|
|
119
124
|
}
|
|
120
|
-
|
|
125
|
+
// Clean after a fix commit: every lens reads the whole branch once more,
|
|
126
|
+
// fresh, before the PR opens (#548). Once per final review; with no fix
|
|
127
|
+
// commit, round 1 was already a full fresh read.
|
|
128
|
+
const ending: FinalReviewAction =
|
|
129
|
+
review.fix_pushed && !review.fresh_started
|
|
130
|
+
? {
|
|
131
|
+
type: 'start_final_review',
|
|
132
|
+
round: review.round + 1,
|
|
133
|
+
from_sha: run_branch.base_sha,
|
|
134
|
+
lenses: [...LENS_NAMES],
|
|
135
|
+
fresh: true,
|
|
136
|
+
}
|
|
137
|
+
: { type: 'pass_final_review' }
|
|
138
|
+
if (review.fix === null) return working([ending])
|
|
139
|
+
// The fresh pass's fix was re-checked: what is still open tops the PR,
|
|
140
|
+
// and the final review never gets stuck on it (#548).
|
|
141
|
+
if (review.fresh_started && !review.fresh) {
|
|
142
|
+
return working([{ type: 'pass_final_review' }])
|
|
143
|
+
}
|
|
121
144
|
const step = fixStep(args)
|
|
122
145
|
if (step !== null) return working([step])
|
|
123
146
|
// A fix round opened only by failing slow tests ends at its push: its
|
|
124
147
|
// gates ran the slow tests on what was pushed, and no lens had a finding
|
|
125
|
-
// to look at again (#563).
|
|
126
|
-
if (review.fix.findings.length === 0)
|
|
127
|
-
return working([{ type: 'pass_final_review' }])
|
|
128
|
-
}
|
|
148
|
+
// to look at again (#563), so the fresh pass comes next.
|
|
149
|
+
if (review.fix.findings.length === 0) return working([ending])
|
|
129
150
|
// The fixes are pushed: the lenses with blocking findings look again.
|
|
130
151
|
return working([
|
|
131
152
|
{
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
amountTokens,
|
|
3
|
+
pendingReplies,
|
|
4
|
+
waitForReply,
|
|
5
|
+
type StuckAction,
|
|
6
|
+
} from './decide-stuck'
|
|
2
7
|
import { repoBusyStuckComment } from './repo-wait-text'
|
|
3
8
|
import {
|
|
4
9
|
pullRequestStuckComment,
|
|
10
|
+
replyAnswer,
|
|
5
11
|
runBudgetDetail,
|
|
6
12
|
runStuckComment,
|
|
7
13
|
} from './stuck-text'
|
|
8
14
|
|
|
9
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
JournalRecord,
|
|
17
|
+
ReplyProblem,
|
|
18
|
+
RunStuckReason,
|
|
19
|
+
} from '../journal/journal-record'
|
|
10
20
|
import type { RunState } from '../journal/replay'
|
|
11
21
|
import { runBudgetOf, runTokensIn } from '../limits/run-budget'
|
|
12
22
|
|
|
@@ -38,13 +48,16 @@ export type RunBudgetAction =
|
|
|
38
48
|
/**
|
|
39
49
|
* The run budget half of the decision step. Pure. Once the run's tokens
|
|
40
50
|
* (`runTokensIn`) reach its budget (the config's `run_budget_tokens`, else
|
|
41
|
-
* the default,
|
|
42
|
-
* reason "run budget": that is its only step, and nothing new starts.
|
|
43
|
-
* spec issue is told, then the run waits for the owner: a bare `retry`
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
51
|
+
* the default, plus every top-up a `retry` granted), the run is stuck with
|
|
52
|
+
* the reason "run budget": that is its only step, and nothing new starts.
|
|
53
|
+
* The spec issue is told, then the run waits for the owner: a bare `retry`
|
|
54
|
+
* tops the budget up by half a budget, rounded down, and `retry +3M` by the
|
|
55
|
+
* amount named (#543; replay adds the top-up); `stop` ends the run. A zero
|
|
56
|
+
* or unreadable amount is sent back (`bad_amount`). The owner's other
|
|
57
|
+
* replies wait until the run carries on. A run whose PR didn't open (#508)
|
|
58
|
+
* or whose repo wait ran out is stuck the same way, told why, and a bare
|
|
59
|
+
* `retry` tries again, adding no budget; an amount there is sent back
|
|
60
|
+
* (`amount_only_for_run_budget`). `null` when
|
|
48
61
|
* the budget has nothing to say: the run is under it, stopping, or its PR
|
|
49
62
|
* is open.
|
|
50
63
|
*
|
|
@@ -62,11 +75,11 @@ export const decideRunBudget = ({
|
|
|
62
75
|
spec_number: number
|
|
63
76
|
}): (RunBudgetAction | StuckAction)[] | null => {
|
|
64
77
|
if (state.stop !== null || state.pull_request !== null) return null
|
|
65
|
-
const { stuck, report,
|
|
78
|
+
const { stuck, report, top_ups } = state.run_budget
|
|
66
79
|
const budget = runBudgetOf({ config: state.config })
|
|
67
80
|
if (stuck === null) {
|
|
68
81
|
const tokens = runTokensIn({ records })
|
|
69
|
-
const allowed = budget
|
|
82
|
+
const allowed = budget + top_ups
|
|
70
83
|
if (tokens < allowed) return null
|
|
71
84
|
return [
|
|
72
85
|
{
|
|
@@ -90,12 +103,48 @@ export const decideRunBudget = ({
|
|
|
90
103
|
word === 'stop' || (word === 'retry' && ticket === null)
|
|
91
104
|
)
|
|
92
105
|
if (reply === undefined) return [waitForReply({ state, spec_number })]
|
|
106
|
+
const ignore = (reason: ReplyProblem): StuckAction[] => [
|
|
107
|
+
{
|
|
108
|
+
type: 'ignore_reply',
|
|
109
|
+
comment_id: reply.comment_id,
|
|
110
|
+
spec_number,
|
|
111
|
+
reason,
|
|
112
|
+
answer: replyAnswer({ problem: reason, named: null, stuck: [] }),
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
if (reply.word === 'stop') {
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
type: 'take_reply',
|
|
119
|
+
comment_id: reply.comment_id,
|
|
120
|
+
word: 'stop',
|
|
121
|
+
ticket: null,
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
if (stuck.reason !== 'run_budget') {
|
|
126
|
+
if (reply.amount !== null) return ignore('amount_only_for_run_budget')
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
type: 'take_reply',
|
|
130
|
+
comment_id: reply.comment_id,
|
|
131
|
+
word: 'retry',
|
|
132
|
+
ticket: null,
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
const top_up =
|
|
137
|
+
reply.amount === null
|
|
138
|
+
? Math.floor(budget / 2)
|
|
139
|
+
: amountTokens({ amount: reply.amount })
|
|
140
|
+
if (top_up === null) return ignore('bad_amount')
|
|
93
141
|
return [
|
|
94
142
|
{
|
|
95
143
|
type: 'take_reply',
|
|
96
144
|
comment_id: reply.comment_id,
|
|
97
|
-
word:
|
|
145
|
+
word: 'retry',
|
|
98
146
|
ticket: null,
|
|
147
|
+
top_up,
|
|
99
148
|
},
|
|
100
149
|
]
|
|
101
150
|
}
|