Repository navigation
feat(cli): add auto-improve command #4161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 72 commits
732af31
9b8ff36
629064c
0ccb3de
836a285
e4e6d02
e8f5101
df8143d
57f3480
c1395a5
c52d3df
1fb6fcf
cd2d158
de77a0a
23a6c9f
a503a6f
88f8e0e
a1e3bea
ec06479
19d026d
8119bf8
fc306c2
a5fd44f
6faaeb6
0415313
7d8a783
76007a8
04434d8
5dbb478
82456a2
bbaed37
dcfc932
e270d69
35f9562
0558dba
2cb0718
6d09046
b16e95f
acdad7e
1e1a724
3b6311c
962a620
c3b4d78
0a95948
030381f
8e08318
71b5bdd
5246dd5
314392c
2191c5e
62850ca
c49a1ae
9f06380
f942827
534a142
0e0efc7
e0795c5
3646559
2d4d5f1
9e2fb85
04254cb
3a4e8fa
d439ce8
cae0e6e
728d2db
e7fa445
8776a57
22bc9dc
a1940b0
695da36
012d5d9
7cf794d
1f10b22
27283c1
433d7e1
5afe0ec
c628af7
5a5ff9d
2ac0778
faab472
11be20e
5221e77
6f48795
46744c5
fbedbaa
d6c13a3
a7d7506
f2d09ed
28a0635
28befb2
bcc28a2
df22089
96e9fd0
13bb4df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| # Auto-Improve Built-In Command Design | ||
|
|
||
| ## Goal | ||
|
|
||
| Add a built-in `/auto-improve` command that runs a session-scoped loop for | ||
| bounded, locally verifiable repository improvements. The command should be useful | ||
| without becoming a hard-coded automation framework: first version keeps the | ||
| actual implementation, testing, repair, delivery, and documentation work | ||
| prompt-driven, while the built-in command owns reliable local state, scheduling, | ||
| status, and source configuration. | ||
|
|
||
| ## User Commands | ||
|
|
||
| Expose four user-facing subcommands: | ||
|
|
||
| - `/auto-improve source` | ||
| - `/auto-improve start --every <interval> [prompt]` | ||
| - `/auto-improve status` | ||
| - `/auto-improve stop` | ||
|
|
||
| `source` is interactive-only. It opens a dialog with checkboxes for GitHub | ||
| issues, GitHub PRs / CI / review comments, and local repository scanning, plus a | ||
| custom source list. Users can add multiple custom source hints, edit existing | ||
| items, and delete items. Custom sources can be used alone or together with | ||
| checked built-in sources. Defaults are all off and an empty custom source list. | ||
|
|
||
| `start` may run even when no source and no prompt are configured. In that case | ||
| the tick prompt tells the agent to do a small baseline repository inspection and | ||
| choose one locally verifiable task. `start` snapshots the current repo-level | ||
| source configuration into the loop; future `source` changes affect only future | ||
| loops. | ||
|
|
||
| ## State Layout | ||
|
|
||
| Store state under `.qwen/auto-improve/`: | ||
|
|
||
| ```text | ||
| .qwen/auto-improve/ | ||
| config.json | ||
| active.json | ||
| loops/ | ||
| <loop-id>/ | ||
| state.json | ||
| summary.md | ||
| runs/ | ||
| index.json | ||
| 001-xxx.md | ||
| ``` | ||
|
|
||
| `config.json` is repository-level default source configuration, including the | ||
| built-in source toggles and ordered custom source hints. `active.json` is a thin | ||
| pointer to the one active loop. First version allows at most one active loop per | ||
| repository. `state.json` belongs to a single loop and contains the cadence, | ||
| loop default branch, source snapshot, delivery policy, start prompt, status, | ||
| stop request flag, current run, last run, and cron job id when available. | ||
| Historical loops remain in `loops/`, but `/auto-improve status` reads only the | ||
| active loop. | ||
|
|
||
| The loop is session-scoped. Exiting Qwen Code is equivalent to stopping the | ||
| loop. If the CLI exits abruptly and leaves `active.json` behind, a later status | ||
| can mark it stale rather than pretending it is still running. | ||
|
|
||
| ## Loop Behavior | ||
|
|
||
| `/auto-improve start --every 2h [prompt]`: | ||
|
|
||
| 1. Refuses to start if another active loop exists. | ||
| 2. Reads `config.json`. | ||
| 3. Captures the current local branch as `targetBranch`. | ||
| 4. Creates a new loop directory with a `state.json`, `summary.md`, and `runs/`. | ||
| 5. Registers a session-only recurring schedule. | ||
| 6. Immediately submits the first tick prompt. | ||
|
|
||
| Each tick is prompt-driven. The prompt instructs the agent to: | ||
|
|
||
| - read the loop state; | ||
| - select exactly one coherent, locally verifiable improvement from the source | ||
| snapshot and optional start prompt, preferring bounded work while making the | ||
| change complete enough to address the selected issue, PR comment, requested | ||
| change, or failing check; | ||
| - create a dedicated issue branch from the repository default branch for | ||
| GitHub issue-derived tasks; | ||
| - create an isolated worktree and branch; | ||
| - implement the change; | ||
| - run appropriate tests; | ||
| - repair and retest up to five times; | ||
| - commit only after tests pass; | ||
| - choose a delivery branch before editing; | ||
| - use a PR's head branch for PR-derived review / CI / comment tasks; | ||
| - prioritize the authenticated user's own open, non-draft PRs for PR-derived | ||
| work; | ||
| - focus on actionable unresolved review comments, requested changes, and | ||
| failing checks instead of already-resolved comments or general comment | ||
| history; | ||
| - for addressed unresolved PR review comments, fix and validate first, then | ||
| reply to each addressed review thread/comment with a concise summary and | ||
| validation result, and resolve the thread; if permissions or API limitations | ||
| prevent replying or resolving, record that in the run doc and final response; | ||
| - skip other users' PRs, CI failures, and review comments unless the user | ||
| explicitly requested them; | ||
| - use the loop default branch for ordinary local/default tasks; | ||
| - use a local-only branch if the correct delivery branch is unclear; | ||
| - never merge a PR-derived fix into the loop default branch unless they are the | ||
| same branch; | ||
| - never push unless the user explicitly requested push in the start prompt or | ||
| selected source; | ||
| - never overwrite or discard user uncommitted work; | ||
| - delete the worktree after success or after five failed repair attempts; | ||
| - update `summary.md`, `runs/index.json`, and one run document for every | ||
| attempted run. | ||
|
|
||
| Successful runs are local commits by default. For PR-derived tasks, the local | ||
| commit belongs to the PR head branch rather than the branch that started the | ||
| loop. The first version does not push unless the user explicitly requested it | ||
| and does not open pull requests. Failed runs delete their worktree and leave | ||
| only the run document. | ||
|
|
||
| ## Stop And Status | ||
|
|
||
| `stop` is graceful. If no run is active, it cancels future scheduling, marks the | ||
| loop stopped, and clears `active.json`. If a run is active, it cancels future | ||
| scheduling and writes `stopRequested: true`; the current run may naturally | ||
| finish, fail, or cancel, but no later tick should start. | ||
|
|
||
| `status` displays the active loop when present. If there is no active loop, it | ||
| falls back to the most recent historical loop so stopped loops remain | ||
| discoverable. Status includes loop id, status, cadence, target branch, source | ||
| snapshot, start prompt, current run, last run, recent run records, and | ||
| next/future schedule information when available. | ||
|
|
||
| ## Implementation Shape | ||
|
|
||
| Implement `/auto-improve` as a built-in command. Use a small hidden | ||
| `/auto-improve tick <loop-id>` subcommand as the scheduled entrypoint; it | ||
| returns `submit_prompt` with the internal tick instructions. The hidden tick is | ||
| not shown in help and is not part of the public UX. | ||
|
|
||
| This keeps the first version simple: program code controls reliable command | ||
| state and scheduling, while the agent remains responsible for the engineering | ||
| workflow inside each improvement run. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -314,6 +314,12 @@ export class Session implements SessionContext { | |
| * process termination is slow. | ||
| */ | ||
| private pendingPromptCompletion: Promise<void> | null = null; | ||
| // onComplete captured from a regular-prompt submit_prompt slash result (e.g. | ||
| // /auto-improve start → markRunCompleted). Prompts are serialized, so a single | ||
| // field is safe; the caller fires it after the turn so the run isn't stranded. | ||
| private pendingSlashOnComplete: | ||
| | ((opts?: { errored?: boolean }) => Promise<void>) | ||
| | undefined; | ||
| private turn: number = 0; | ||
| private readonly runtimeBaseDir: string; | ||
|
|
||
|
|
@@ -666,11 +672,36 @@ export class Session implements SessionContext { | |
| try { | ||
| const result = await this.#executePrompt(params, pendingSend); | ||
| this.pendingPrompt = null; | ||
| // Fire a submit_prompt slash onComplete (e.g. markRunCompleted) now that | ||
| // the turn finished, so /auto-improve start in ACP mode clears currentRun | ||
| // instead of deadlocking later ticks. Errored unless the turn ended | ||
| // cleanly. (#executePrompt discards onComplete, so it's threaded via the | ||
| // instance field.) | ||
| const onComplete = this.pendingSlashOnComplete; | ||
| this.pendingSlashOnComplete = undefined; | ||
| if (onComplete) { | ||
| await onComplete( | ||
| result.stopReason === 'end_turn' ? undefined : { errored: true }, | ||
| ).catch((e: unknown) => | ||
| debugLogger.warn('slash onComplete threw:', e), | ||
| ); | ||
| } | ||
| this.#startCronSchedulerIfNeeded(); | ||
| // Drain any cron prompts that queued while the prompt was active | ||
| void this.#drainCronQueue(); | ||
| void this.#drainNotificationQueue(); | ||
| return result; | ||
| } catch (error) { | ||
| // The turn threw — still fire onComplete (errored) so the run isn't | ||
| // stranded, then re-throw. | ||
| const onComplete = this.pendingSlashOnComplete; | ||
| this.pendingSlashOnComplete = undefined; | ||
| if (onComplete) { | ||
| await onComplete({ errored: true }).catch((e: unknown) => | ||
| debugLogger.warn('slash onComplete (errored) threw:', e), | ||
| ); | ||
| } | ||
| throw error; | ||
| } finally { | ||
| resolveCompletion(); | ||
| } | ||
|
|
@@ -727,6 +758,12 @@ export class Session implements SessionContext { | |
| this.settings, | ||
| ); | ||
|
|
||
| // Capture onComplete before #processSlashCommandResult discards it | ||
| // (it returns only the content). The caller fires it after the turn. | ||
| if (slashCommandResult.type === 'submit_prompt') { | ||
| this.pendingSlashOnComplete = slashCommandResult.onComplete; | ||
| } | ||
|
|
||
| parts = await this.#processSlashCommandResult( | ||
| slashCommandResult, | ||
| params.prompt, | ||
|
|
@@ -1521,11 +1558,46 @@ export class Session implements SessionContext { | |
| const promptId = | ||
| this.config.getSessionId() + '########cron' + Date.now(); | ||
|
|
||
| let slashOnComplete: | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| | ((opts?: { errored?: boolean }) => Promise<void>) | ||
| | undefined; | ||
| let slashOnCompleteErrored = false; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same theme as the non-interactive coverage gap — the Session.ts cron error/cancel branches only have happy-path coverage. The branches are now correct (errored vs cancelled distinguished); exercising agent-error / worktree-failure / cancel-mid-tick needs fixtures simulating those mid-stream. Tracking alongside the cron test-harness follow-up; leaving open. |
||
|
|
||
| try { | ||
| let promptParts: Part[] = [{ text: prompt }]; | ||
| if (isSlashCommand(prompt)) { | ||
| const slashCommandResult = await handleSlashCommand( | ||
| prompt, | ||
| ac, | ||
| this.config, | ||
| this.settings, | ||
| ); | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| if ( | ||
| slashCommandResult.type === 'submit_prompt' && | ||
| slashCommandResult.onComplete | ||
| ) { | ||
| slashOnComplete = slashCommandResult.onComplete; | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| } | ||
| const processedParts = await this.#processSlashCommandResult( | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| slashCommandResult, | ||
| [{ type: 'text', text: prompt }], | ||
| ); | ||
| if (processedParts === null) { | ||
| // No prompt was produced — don't let the finally record this as a | ||
| // successful run. | ||
| slashOnCompleteErrored = true; | ||
| return; | ||
| } | ||
| promptParts = processedParts; | ||
| } | ||
| const promptText = promptParts | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| .map((part) => part.text ?? JSON.stringify(part)) | ||
| .join(''); | ||
|
|
||
| // Echo the cron prompt as a user message so the client sees it | ||
| await this.sendUpdate({ | ||
| sessionUpdate: 'user_message_chunk', | ||
| content: { type: 'text', text: prompt }, | ||
| content: { type: 'text', text: promptText }, | ||
| _meta: { source: 'cron' }, | ||
| }); | ||
|
|
||
|
|
@@ -1534,11 +1606,14 @@ export class Session implements SessionContext { | |
| const cronReminders = await this.#buildInitialSystemReminders(); | ||
| let nextMessage: Content | null = { | ||
| role: 'user', | ||
| parts: [...cronReminders, { text: prompt }], | ||
| parts: [...cronReminders, ...promptParts], | ||
| }; | ||
|
|
||
| while (nextMessage !== null) { | ||
| if (ac.signal.aborted) return; | ||
| if (ac.signal.aborted) { | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| slashOnCompleteErrored = true; | ||
| return; | ||
| } | ||
|
|
||
| const functionCalls: FunctionCall[] = []; | ||
| let usageMetadata: GenerateContentResponseUsageMetadata | null = | ||
|
|
@@ -1558,13 +1633,20 @@ export class Session implements SessionContext { | |
| if (sendResult.stopReason === 'max_tokens') { | ||
| this.#stopCronAfterTokenLimit(); | ||
| } | ||
| // The turn produced no response (cancelled / token-limited) — it | ||
| // did not complete successfully, so don't let the finally mark it | ||
| // as a successful run. | ||
| slashOnCompleteErrored = true; | ||
| return; | ||
| } | ||
| const responseStream = sendResult.responseStream; | ||
| nextMessage = null; | ||
|
|
||
| for await (const resp of responseStream) { | ||
| if (ac.signal.aborted) return; | ||
| if (ac.signal.aborted) { | ||
| slashOnCompleteErrored = true; | ||
| return; | ||
| } | ||
|
|
||
| if ( | ||
| resp.type === StreamEventType.CHUNK && | ||
|
|
@@ -1621,11 +1703,24 @@ export class Session implements SessionContext { | |
| } | ||
| } | ||
| } catch (error) { | ||
| slashOnCompleteErrored = true; | ||
| if (ac.signal.aborted) return; | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| debugLogger.error('Error processing cron prompt:', error); | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
| await this.messageEmitter.emitAgentMessage(`[cron error] ${msg}`); | ||
| } finally { | ||
| // Fire onComplete from submit_prompt (e.g. markRunCompleted) | ||
| // after the model turn finishes, even on error paths. | ||
| if (slashOnComplete) { | ||
| try { | ||
| await slashOnComplete( | ||
| slashOnCompleteErrored ? { errored: true } : undefined, | ||
| ); | ||
| } catch (e) { | ||
| // swallow — markRunCompleted is idempotent | ||
| debugLogger.warn('slashOnComplete threw:', e); | ||
| } | ||
| } | ||
| if (this.cronAbortController === ac) { | ||
| this.cronAbortController = null; | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.