Repository navigation
feat(loop): inject a .qwen/loop.md task file at fire time via sentinels #5890
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 3 commits
f1879c0
7b630fe
f36a5c8
9b70f16
91759a8
a6da917
6c7466d
0e2128f
a278fd0
89f5ff8
1ad5362
3e19ba5
60edea8
c0a962a
d0561f0
565398e
e77e66e
d9db495
f402bae
300060c
5acd11a
eb0f082
44f45c0
21aebdd
2f43aeb
75c6c37
9271c43
54082f0
2116715
73df2ef
0198307
4ab4012
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 | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |||||||||||||||||||||
| * SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import * as os from 'node:os'; | ||||||||||||||||||||||
| import type { | ||||||||||||||||||||||
| Content, | ||||||||||||||||||||||
| FunctionCall, | ||||||||||||||||||||||
|
|
@@ -33,6 +34,8 @@ import { | |||||||||||||||||||||
| AuthType, | ||||||||||||||||||||||
| ApprovalMode, | ||||||||||||||||||||||
| CompressionStatus, | ||||||||||||||||||||||
| detectLoopSentinel, | ||||||||||||||||||||||
| LoopTickResolver, | ||||||||||||||||||||||
| convertToFunctionResponse, | ||||||||||||||||||||||
| createDuplicateProviderToolCallResponse, | ||||||||||||||||||||||
| createDebugLogger, | ||||||||||||||||||||||
|
|
@@ -664,6 +667,13 @@ export class Session implements SessionContext { | |||||||||||||||||||||
| private cronQueue: CronQueueItem[] = []; | ||||||||||||||||||||||
| private cronProcessing = false; | ||||||||||||||||||||||
| private cronAbortController: AbortController | null = null; | ||||||||||||||||||||||
| // Resolves the `<<loop.md>>` / `<<loop.md-dynamic>>` sentinels at fire time. | ||||||||||||||||||||||
| // Lazily created on the first loop tick; its content cache is reset on | ||||||||||||||||||||||
| // compaction (see #sendMessageStreamWithAutoCompression) and it is rebuilt if | ||||||||||||||||||||||
| // the working dir changes (e.g. /cd) so it always reads the current project's | ||||||||||||||||||||||
| // loop.md. | ||||||||||||||||||||||
| private loopTickResolver: LoopTickResolver | null = null; | ||||||||||||||||||||||
| private loopTickResolverRoot: string | null = null; | ||||||||||||||||||||||
| private cronCompletion: Promise<void> | null = null; | ||||||||||||||||||||||
| private cronDisabledByTokenLimit = false; | ||||||||||||||||||||||
| private lastPromptTokenCount = 0; | ||||||||||||||||||||||
|
|
@@ -1926,6 +1936,10 @@ export class Session implements SessionContext { | |||||||||||||||||||||
| compressionInfo = compressed; | ||||||||||||||||||||||
| this.#recordCompressionTokenCount(compressed); | ||||||||||||||||||||||
| if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { | ||||||||||||||||||||||
| // Context was just compacted; a loop.md tick must re-deliver the full | ||||||||||||||||||||||
|
Collaborator
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. [Critical] This cache reset only covers automatic sends. ACP — GPT-5 via Qwen Code /review |
||||||||||||||||||||||
| // task block (a short reminder refers back to a message that is no | ||||||||||||||||||||||
| // longer in context). | ||||||||||||||||||||||
| this.loopTickResolver?.resetCache(); | ||||||||||||||||||||||
| const reasonClause = | ||||||||||||||||||||||
| compressed.triggerReason === 'image_overflow' | ||||||||||||||||||||||
| ? `accumulated enough tool screenshots to trigger compaction for ${this.config.getModel()}` | ||||||||||||||||||||||
|
|
@@ -2427,6 +2441,20 @@ export class Session implements SessionContext { | |||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| #getLoopTickResolver(): LoopTickResolver { | ||||||||||||||||||||||
|
Collaborator
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. [Suggestion] No Session-level test exercises the working-dir-change rebuild path here. All existing Session loop tests use a single fixed A regression that drops the Test needed: Fire two loop ticks with — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||
| const root = this.config.getWorkingDir(); | ||||||||||||||||||||||
| // Rebuild if the working dir changed (e.g. /cd) so loop.md resolves against | ||||||||||||||||||||||
| // the current project; a fresh resolver also correctly re-delivers full. | ||||||||||||||||||||||
| if (!this.loopTickResolver || this.loopTickResolverRoot !== root) { | ||||||||||||||||||||||
| this.loopTickResolver = new LoopTickResolver({ | ||||||||||||||||||||||
|
Collaborator
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. [Critical] — GPT-5 via Qwen Code /review |
||||||||||||||||||||||
| projectRoot: root, | ||||||||||||||||||||||
| homeDir: os.homedir(), | ||||||||||||||||||||||
|
Collaborator
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. [Suggestion] The home/global With Suggested fix: resolve the home candidate's base from — claude-opus-4-8[1m] via Qwen Code /qreview |
||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| this.loopTickResolverRoot = root; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return this.loopTickResolver; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Executes a single cron-fired prompt: echoes it as a user message with | ||||||||||||||||||||||
| * `_meta.source='cron'`, streams the model response, and handles tool calls. | ||||||||||||||||||||||
|
|
@@ -2460,10 +2488,38 @@ export class Session implements SessionContext { | |||||||||||||||||||||
| async () => { | ||||||||||||||||||||||
| let turnCount = 0; | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| // A `<<loop.md>>` / `<<loop.md-dynamic>>` sentinel is expanded at | ||||||||||||||||||||||
| // fire time into the loop.md task block — full on the first or a | ||||||||||||||||||||||
| // changed fire, a short reminder when unchanged. Non-sentinel | ||||||||||||||||||||||
| // prompts pass through untouched. | ||||||||||||||||||||||
| const loopMode = detectLoopSentinel(prompt); | ||||||||||||||||||||||
|
Collaborator
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. [Critical] This expansion happens after the scheduled prompt has already been approved/classified as the harmless literal sentinel. Because — GPT-5 via Qwen Code /review
Collaborator
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. [Suggestion]
The re-arm instruction is about the firing mechanism (does this job auto-repeat?), which
Suggested change
— claude-opus-4-8 via Qwen Code /qreview |
||||||||||||||||||||||
| const loopTick = loopMode | ||||||||||||||||||||||
| ? await this.#getLoopTickResolver().resolve(loopMode) | ||||||||||||||||||||||
| : null; | ||||||||||||||||||||||
| const modelText = loopTick ? loopTick.modelText : prompt; | ||||||||||||||||||||||
|
Collaborator
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. [Critical] — GPT-5 via Qwen Code /review |
||||||||||||||||||||||
| if (loopTick) { | ||||||||||||||||||||||
| debugLogger.debug( | ||||||||||||||||||||||
|
Collaborator
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. [Suggestion] The tick delivery debug log cannot distinguish a transient-error tick from a genuinely-absent file. Both Add a
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||
| `loop tick: mode=${loopMode} delivery=${ | ||||||||||||||||||||||
| loopTick.full | ||||||||||||||||||||||
| ? 'full' | ||||||||||||||||||||||
| : loopTick.sourcePath | ||||||||||||||||||||||
| ? 'reminder' | ||||||||||||||||||||||
| : 'absent' | ||||||||||||||||||||||
| } path=${loopTick.sourcePath ?? 'none'}`, | ||||||||||||||||||||||
|
Collaborator
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. [Suggestion] Debug log leaks the absolute
Suggested change
(This requires — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| // For a loop tick echo a stable label, never the bare sentinel or | ||||||||||||||||||||||
| // the full task dump; otherwise echo the prompt verbatim. | ||||||||||||||||||||||
| const echoText = loopTick | ||||||||||||||||||||||
| ? loopTick.sourcePath | ||||||||||||||||||||||
| ? `Loop tick — tasks from ${loopTick.sourcePath}` | ||||||||||||||||||||||
| : 'Loop tick — loop.md not present' | ||||||||||||||||||||||
|
Collaborator
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. [Suggestion] The new absent-file branch ( — GPT-5 via Qwen Code /review |
||||||||||||||||||||||
| : prompt; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // 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: echoText }, | ||||||||||||||||||||||
| _meta: { source: item.source }, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -2472,7 +2528,7 @@ export class Session implements SessionContext { | |||||||||||||||||||||
| const cronReminders = await this.#buildInitialSystemReminders(); | ||||||||||||||||||||||
| let nextMessage: Content | null = { | ||||||||||||||||||||||
| role: 'user', | ||||||||||||||||||||||
| parts: [...cronReminders, { text: prompt }], | ||||||||||||||||||||||
| parts: [...cronReminders, { text: modelText }], | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| while (nextMessage !== null) { | ||||||||||||||||||||||
|
|
@@ -2501,6 +2557,13 @@ export class Session implements SessionContext { | |||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| const responseStream = sendResult.responseStream; | ||||||||||||||||||||||
| if (loopTick && turnCount === 1) { | ||||||||||||||||||||||
| // The block reached the model (the send started); commit it so | ||||||||||||||||||||||
| // the next tick can detect "unchanged". Deferring the commit | ||||||||||||||||||||||
| // to here keeps an abort before delivery from poisoning the | ||||||||||||||||||||||
| // cache into a dangling short reminder. | ||||||||||||||||||||||
| this.loopTickResolver?.markDelivered(); | ||||||||||||||||||||||
|
Collaborator
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. [Suggestion] Consider having — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| nextMessage = null; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| for await (const resp of responseStream) { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import { | |
| uiTelemetryService, | ||
| parseAndFormatApiError, | ||
| createDebugLogger, | ||
| detectLoopSentinel, | ||
| SendMessageType, | ||
| restoreWorktreeContext, | ||
| TeamEventType, | ||
|
|
@@ -1521,6 +1522,15 @@ export async function runNonInteractive( | |
| }; | ||
|
|
||
| scheduler.start((job: { prompt: string; cronExpr?: string }) => { | ||
| // loop.md sentinel expansion is interactive-only for now; in a | ||
| // headless run a bare `<<loop.md>>` sentinel would reach the | ||
| // model as its prompt with no task content, so skip the tick | ||
| // (no-op) instead of enqueuing it. Full headless loop.md support | ||
| // is a follow-up. | ||
| if (detectLoopSentinel(job.prompt)) { | ||
| checkCronDone(); | ||
|
Collaborator
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. [Critical] For headless runs, skipping a loop sentinel calls — GPT-5 via Qwen Code /review |
||
| return; | ||
| } | ||
| const label = job.prompt.slice(0, 40); | ||
| localQueue.push({ | ||
| displayText: `${job.cronExpr === '@wakeup' ? 'Loop' : 'Cron'}: ${label}`, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,4 +87,13 @@ If the interval does not cleanly divide its unit (for example `7m` gives uneven | |
| - If it is a slash command, invoke it via the Skill tool. | ||
| - Otherwise, act on it directly. | ||
|
|
||
| ## loop.md task-file mode | ||
|
|
||
| Use this when the user wants the loop to work a task list kept in a file (they say "work through my loop.md", "loop over the tasks in .qwen/loop.md", or point at such a file). Tasks live in `.qwen/loop.md` (project) or `~/.qwen/loop.md` (home; project wins). Instead of a natural-language prompt, set the loop's `prompt` to a sentinel so each fire re-reads the file: | ||
|
Collaborator
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. [Critical] This new sentinel mode is only resolved in ACP. The normal interactive TUI scheduler still enqueues — GPT-5 via Qwen Code /review |
||
|
|
||
| - Self-paced (no interval) → LoopWakeup `prompt`: `<<loop.md-dynamic>>` | ||
| - Fixed interval → CronCreate `prompt`: `<<loop.md>>` (with `recurring: true`, and `durable: true` if persistence is implied) | ||
|
|
||
| At each fire you receive either the full task list (first delivery, after the file changes, or after a compaction) or a short reminder to keep working the list established earlier. Work the tasks; in self-paced mode re-arm LoopWakeup with `<<loop.md-dynamic>>` only when continued follow-up is useful (same "don't re-arm if complete/blocked" rules as the prompt-only path). If `.qwen/loop.md` is absent at fire time, treat the tick as a no-op. Confirm to the user in plain language ("looping over your `.qwen/loop.md` task list…"), not the raw sentinel. | ||
|
|
||
| ## Input | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Critical] The heading format was changed from
"tasks from"to"loop.md tasks from"in the production code (tickHeading()) and the core unit tests were updated, but this Session integration test assertion was not. The CLI test suite fails with:This is likely the cause of the CI
Test (ubuntu-latest, Node 22.x)failure.— qwen3.7-max via Qwen Code /review