-
Notifications
You must be signed in to change notification settings - Fork 3.1k
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 all 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
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |||||||||||||||||||||||||||||||||||||
| * SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import * as os from 'node:os'; | ||||||||||||||||||||||||||||||||||||||
| import * as path from 'node:path'; | ||||||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||||||
| Content, | ||||||||||||||||||||||||||||||||||||||
| FunctionCall, | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -28,11 +30,14 @@ import type { | |||||||||||||||||||||||||||||||||||||
| GoalTerminalEvent, | ||||||||||||||||||||||||||||||||||||||
| ToolCallRequestInfo, | ||||||||||||||||||||||||||||||||||||||
| ToolCallResponseInfo, | ||||||||||||||||||||||||||||||||||||||
| LoopTickResult, | ||||||||||||||||||||||||||||||||||||||
| } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||
| AuthType, | ||||||||||||||||||||||||||||||||||||||
| ApprovalMode, | ||||||||||||||||||||||||||||||||||||||
| CompressionStatus, | ||||||||||||||||||||||||||||||||||||||
| detectLoopSentinel, | ||||||||||||||||||||||||||||||||||||||
| LoopTickResolver, | ||||||||||||||||||||||||||||||||||||||
| convertToFunctionResponse, | ||||||||||||||||||||||||||||||||||||||
| createDuplicateProviderToolCallResponse, | ||||||||||||||||||||||||||||||||||||||
| findRepeatedDuplicateProviderToolCall, | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -212,6 +217,23 @@ const MAX_MID_TURN_RESOURCE_TEXT_LENGTH = 100_000; | |||||||||||||||||||||||||||||||||||||
| // conforming-but-busy client, while a client that never answers stops | ||||||||||||||||||||||||||||||||||||||
| // costing a stall per tool batch after a few batches. | ||||||||||||||||||||||||||||||||||||||
| const MID_TURN_QUEUE_DRAIN_MAX_TIMEOUT_STRIKES = 3; | ||||||||||||||||||||||||||||||||||||||
| // fs codes that let a `dynamic` (self-paced) loop treat a THROWN loop.md | ||||||||||||||||||||||||||||||||||||||
| // sentinel-resolution as transient — degrade to a no-op re-arm tick so the loop | ||||||||||||||||||||||||||||||||||||||
| // survives — instead of re-throwing (which ends it: the firing wakeup is already | ||||||||||||||||||||||||||||||||||||||
| // consumed, so only an end-of-turn re-arm keeps it alive). readLoopTaskFile only | ||||||||||||||||||||||||||||||||||||||
| // re-throws EACCES/EIO/EBUSY/EPERM (it skips ENOENT/EISDIR/ENOTDIR/ELOOP/… to its | ||||||||||||||||||||||||||||||||||||||
| // own `missing` → no-op path); EISDIR/ENOTDIR stay here as defense-in-depth for | ||||||||||||||||||||||||||||||||||||||
| // the lstat→open TOCTOU race (path swapped to a dir/non-dir mid-read) should that | ||||||||||||||||||||||||||||||||||||||
| // internal skip ever narrow. ENOENT is omitted on purpose: "absent" is not a | ||||||||||||||||||||||||||||||||||||||
| // transient read failure and can never reach this catch. | ||||||||||||||||||||||||||||||||||||||
| const TRANSIENT_FS_CODES: readonly string[] = [ | ||||||||||||||||||||||||||||||||||||||
| 'EACCES', | ||||||||||||||||||||||||||||||||||||||
| 'EIO', | ||||||||||||||||||||||||||||||||||||||
| 'EBUSY', | ||||||||||||||||||||||||||||||||||||||
| 'EPERM', | ||||||||||||||||||||||||||||||||||||||
| 'EISDIR', | ||||||||||||||||||||||||||||||||||||||
| 'ENOTDIR', | ||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| type DrainedMidTurnMessage = | ||||||||||||||||||||||||||||||||||||||
| | { kind: 'text'; message: string } | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -667,6 +689,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; | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -1927,6 +1956,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(); | ||||||||||||||||||||||||||||||||||||||
|
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] When auto-compression triggers during the first turn's send of a loop tick's full block, the ordering is:
This is a realistic scenario since accumulated chat + tool screenshots are exactly what triggers compression in long-running sessions. Suggested fix: change — qwen3.7-max 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 If this call were removed, a post-compaction tick would send a dangling short reminder pointing to a task block that was just evicted from context. Suggested: add a Session test that (1) fires a loop.md tick (full delivery), (2) triggers compaction, (3) fires the sentinel again, (4) asserts the second tick is also |
||||||||||||||||||||||||||||||||||||||
| const reasonClause = | ||||||||||||||||||||||||||||||||||||||
| compressed.triggerReason === 'image_overflow' | ||||||||||||||||||||||||||||||||||||||
| ? `accumulated enough tool screenshots to trigger compaction for ${this.config.getModel()}` | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -2445,6 +2478,44 @@ 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] Several Session-level integration paths introduced by this PR lack tests:
The unit tests in — qwen3.7-max 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] 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) { | ||||||||||||||||||||||||||||||||||||||
| // Resolve the home/global loop.md from the QWEN_HOME-aware global dir (the | ||||||||||||||||||||||||||||||||||||||
| // rest of Qwen honors QWEN_HOME for `.qwen`); reading raw os.homedir() here | ||||||||||||||||||||||||||||||||||||||
| // would always hit the real `~/.qwen` and ignore a relocated config home. | ||||||||||||||||||||||||||||||||||||||
| const homeQwenDir = Storage.getGlobalQwenDir(); | ||||||||||||||||||||||||||||||||||||||
| // Confinement root for the home candidate's resolved target: $QWEN_HOME | ||||||||||||||||||||||||||||||||||||||
| // when set (it IS the global dir), else $HOME — keeps the earlier | ||||||||||||||||||||||||||||||||||||||
| // confinement (an in-root dotfile symlink resolves; an escape is refused). | ||||||||||||||||||||||||||||||||||||||
| // The `|| path.dirname(homeQwenDir)` guards an empty os.homedir() (minimal | ||||||||||||||||||||||||||||||||||||||
| // containers with no HOME): an empty root makes isWithin('', target) always | ||||||||||||||||||||||||||||||||||||||
| // true, trivially bypassing the symlink confinement. homeQwenDir | ||||||||||||||||||||||||||||||||||||||
| // (Storage.getGlobalQwenDir()) is always non-empty, so its parent is a | ||||||||||||||||||||||||||||||||||||||
| // sound non-empty fallback root. | ||||||||||||||||||||||||||||||||||||||
| const homeConfineRoot = | ||||||||||||||||||||||||||||||||||||||
| (process.env['QWEN_HOME'] ? homeQwenDir : os.homedir()) || | ||||||||||||||||||||||||||||||||||||||
| path.dirname(homeQwenDir); | ||||||||||||||||||||||||||||||||||||||
| 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: homeConfineRoot, | ||||||||||||||||||||||||||||||||||||||
|
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. [Medium] Home confinement scope is When The home candidate is always read regardless of workspace trust, making this exploitable without any trust-gate bypass. An attacker who can create or replace Trade-off: Narrowing confinement to |
||||||||||||||||||||||||||||||||||||||
| homeQwenDir, | ||||||||||||||||||||||||||||||||||||||
| // The project `.qwen/loop.md` is repo-controlled, so an untrusted folder | ||||||||||||||||||||||||||||||||||||||
| // must not read it and feed it to the model (mirrors getProjectHooks()'s | ||||||||||||||||||||||||||||||||||||||
| // trust gate). The home/global `~/.qwen/loop.md` is user-owned and stays | ||||||||||||||||||||||||||||||||||||||
| // allowed. Pass a getter, not a snapshot: isTrustedFolder() can flip | ||||||||||||||||||||||||||||||||||||||
| // mid-session on an IDE workspace-trust update, and the resolver outlives | ||||||||||||||||||||||||||||||||||||||
| // a single tick — re-read it on every resolve() so a trusted→untrusted | ||||||||||||||||||||||||||||||||||||||
| // flip stops reading the project file immediately. | ||||||||||||||||||||||||||||||||||||||
| allowProjectFile: () => this.config.isTrustedFolder(), | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -2478,10 +2549,114 @@ 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 |
||||||||||||||||||||||||||||||||||||||
| let loopTick: LoopTickResult | null = null; | ||||||||||||||||||||||||||||||||||||||
| if (loopMode) { | ||||||||||||||||||||||||||||||||||||||
| const resolver = this.#getLoopTickResolver(); | ||||||||||||||||||||||||||||||||||||||
| // Capture folder-trust ONCE for this tick and thread it through | ||||||||||||||||||||||||||||||||||||||
| // both the resolve probe and the error path. isTrustedFolder() | ||||||||||||||||||||||||||||||||||||||
| // can flip mid-tick (an IDE workspace-trust update), so two | ||||||||||||||||||||||||||||||||||||||
| // separate reads could let the sanitized error name a different | ||||||||||||||||||||||||||||||||||||||
| // candidate set than resolve() actually probed. | ||||||||||||||||||||||||||||||||||||||
| const trustedAtResolve = this.config.isTrustedFolder(); | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| loopTick = await resolver.resolve(loopMode, trustedAtResolve); | ||||||||||||||||||||||||||||||||||||||
| } catch (resolveErr) { | ||||||||||||||||||||||||||||||||||||||
| // resolve() reads .qwen/loop.md (project or home/global); an | ||||||||||||||||||||||||||||||||||||||
| // EACCES/EIO here is a sentinel-RESOLUTION failure, not a | ||||||||||||||||||||||||||||||||||||||
| // model-call failure — tag it so the two are distinguishable | ||||||||||||||||||||||||||||||||||||||
| // in logs. | ||||||||||||||||||||||||||||||||||||||
| const code = | ||||||||||||||||||||||||||||||||||||||
| (resolveErr as NodeJS.ErrnoException).code ?? 'unknown'; | ||||||||||||||||||||||||||||||||||||||
| // Full detail — including the raw fs error's ABSOLUTE loop.md | ||||||||||||||||||||||||||||||||||||||
| // path (OS username + dir layout) — stays in this LOCAL debug | ||||||||||||||||||||||||||||||||||||||
| // log only; debug logs are never sent to the ACP client. | ||||||||||||||||||||||||||||||||||||||
| debugLogger.warn( | ||||||||||||||||||||||||||||||||||||||
| `loop.md sentinel resolution failed (mode=${loopMode}, code=${code}) — check .qwen/loop.md permissions/IO`, | ||||||||||||||||||||||||||||||||||||||
| resolveErr, | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||
| loopMode === 'dynamic' && | ||||||||||||||||||||||||||||||||||||||
| TRANSIENT_FS_CODES.includes(code) | ||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||
| // A `dynamic` (self-paced) loop is kept alive ONLY by the | ||||||||||||||||||||||||||||||||||||||
| // model re-arming LoopWakeup at the end of each turn; the | ||||||||||||||||||||||||||||||||||||||
| // firing wakeup was already consumed, so throwing here (no | ||||||||||||||||||||||||||||||||||||||
| // turn → no re-arm) would silently kill the loop forever on a | ||||||||||||||||||||||||||||||||||||||
| // transient hiccup (EACCES/EIO, or a Windows editor/AV briefly | ||||||||||||||||||||||||||||||||||||||
|
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 dynamic branch catches ALL errors from Consider filtering to known fs error codes and re-throwing unexpected errors:
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||
| // locking the file). Degrade to a no-op tick mirroring the | ||||||||||||||||||||||||||||||||||||||
| // absent path so the model still re-arms and the loop survives. | ||||||||||||||||||||||||||||||||||||||
| // (`cron` re-fires on its own next interval, so it still | ||||||||||||||||||||||||||||||||||||||
| // throws below.) The captured trust names the SAME candidate | ||||||||||||||||||||||||||||||||||||||
| // set the probe used; the errno (no absolute path) is noted. | ||||||||||||||||||||||||||||||||||||||
| // Only KNOWN-transient codes degrade: an unexpected error | ||||||||||||||||||||||||||||||||||||||
| // (TypeError / assertion → code 'unknown') falls through to the | ||||||||||||||||||||||||||||||||||||||
| // throw so the real bug surfaces instead of an infinite no-op | ||||||||||||||||||||||||||||||||||||||
| // cycle. | ||||||||||||||||||||||||||||||||||||||
| loopTick = resolver.buildTransientErrorTick( | ||||||||||||||||||||||||||||||||||||||
| loopMode, | ||||||||||||||||||||||||||||||||||||||
| trustedAtResolve, | ||||||||||||||||||||||||||||||||||||||
| code, | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||
| // Reached by `cron` (re-fires on its own next interval) and by | ||||||||||||||||||||||||||||||||||||||
| // `dynamic` with an UNEXPECTED (non-transient) error — both | ||||||||||||||||||||||||||||||||||||||
| // surface rather than silently degrade. Re-throw a SANITIZED | ||||||||||||||||||||||||||||||||||||||
| // error: the outer catch forwards error.message verbatim to the | ||||||||||||||||||||||||||||||||||||||
| // client via emitAgentMessage, | ||||||||||||||||||||||||||||||||||||||
| // so re-throwing the raw fs error would leak that absolute | ||||||||||||||||||||||||||||||||||||||
| // path. Surface only the candidate labels + errno code via the | ||||||||||||||||||||||||||||||||||||||
| // shared absentLocations() — reusing the QWEN_HOME-aware home | ||||||||||||||||||||||||||||||||||||||
| // label (never a hardcoded `~/.qwen`) and naming the project | ||||||||||||||||||||||||||||||||||||||
| // candidate only when it was actually read (the captured trust | ||||||||||||||||||||||||||||||||||||||
| // matches the resolve() probe, so an untrusted folder can't | ||||||||||||||||||||||||||||||||||||||
| // falsely claim `(project)`). | ||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||
| `loop.md resolution failed (${code}) for ${resolver.absentLocations( | ||||||||||||||||||||||||||||||||||||||
| trustedAtResolve, | ||||||||||||||||||||||||||||||||||||||
| )}`, | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| 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. [Critical] Absolute filesystem path leaks to the ACP client via the error propagation channel.
The inner catch's
Suggested change
This preserves the diagnostic code while stripping the embedded absolute path.
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 debug log here reports
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.sourceLabel | ||||||||||||||||||||||||||||||||||||||
| ? 'reminder' | ||||||||||||||||||||||||||||||||||||||
| : 'absent' | ||||||||||||||||||||||||||||||||||||||
| } source=${loopTick.sourceLabel ?? 'none'} transient=${ | ||||||||||||||||||||||||||||||||||||||
| loopTick.transientError ?? false | ||||||||||||||||||||||||||||||||||||||
| }`, | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| // For a loop tick echo a stable, relative label — never the bare | ||||||||||||||||||||||||||||||||||||||
| // sentinel or the full task dump (and the resolver never hands back | ||||||||||||||||||||||||||||||||||||||
| // the absolute path, which would leak the OS username / dir layout | ||||||||||||||||||||||||||||||||||||||
| // into the ACP client UI); otherwise echo the prompt verbatim. | ||||||||||||||||||||||||||||||||||||||
| const echoText = loopTick | ||||||||||||||||||||||||||||||||||||||
|
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] Three-level nested ternary is hard to parse at a glance. Consider extracting to if/else:
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||
| ? loopTick.sourceLabel | ||||||||||||||||||||||||||||||||||||||
| ? `Loop tick — tasks from ${loopTick.sourceLabel}` | ||||||||||||||||||||||||||||||||||||||
|
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] When The user would see "Loop tick — loop.md not present" when in reality the file exists but there was a temporary read error. This is misleading. Consider distinguishing the two cases: : loopTick.sourceLabel
? `Loop tick — tasks from ${loopTick.sourceLabel}`
: loopTick.error
? 'Loop tick — loop.md temporarily unavailable'
: 'Loop tick — loop.md not present'Or, have
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] When |
||||||||||||||||||||||||||||||||||||||
| : // A transient-error tick (buildTransientErrorTick) resolved a | ||||||||||||||||||||||||||||||||||||||
| // file but couldn't read it this tick; it deliberately omits | ||||||||||||||||||||||||||||||||||||||
| // sourceLabel, so don't conflate it with a genuinely-absent | ||||||||||||||||||||||||||||||||||||||
| // loop.md. No errno/path here — those stay in the model text. | ||||||||||||||||||||||||||||||||||||||
| loopTick.transientError | ||||||||||||||||||||||||||||||||||||||
| ? 'Loop tick — loop.md temporarily unavailable' | ||||||||||||||||||||||||||||||||||||||
| : 'Loop tick — loop.md not present' | ||||||||||||||||||||||||||||||||||||||
| : 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 }, | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
@@ -2490,7 +2665,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) { | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -2519,6 +2694,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 deferring — qwen3.7-max 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] 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 | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| import type { | ||||||||||||||||||||||
| Config, | ||||||||||||||||||||||
| CronJob, | ||||||||||||||||||||||
| ToolRegistry, | ||||||||||||||||||||||
| ServerGeminiStreamEvent, | ||||||||||||||||||||||
| SessionMetrics, | ||||||||||||||||||||||
|
|
@@ -22,9 +23,15 @@ import { | |||||||||||||||||||||
| ApprovalMode, | ||||||||||||||||||||||
| SendMessageType, | ||||||||||||||||||||||
| LoopType, | ||||||||||||||||||||||
| CronScheduler, | ||||||||||||||||||||||
| LOOP_SENTINEL_CRON, | ||||||||||||||||||||||
| LOOP_SENTINEL_DYNAMIC, | ||||||||||||||||||||||
| } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||
| import type { Part } from '@google/genai'; | ||||||||||||||||||||||
| import { runNonInteractive } from './nonInteractiveCli.js'; | ||||||||||||||||||||||
| import { | ||||||||||||||||||||||
| runNonInteractive, | ||||||||||||||||||||||
| skipHeadlessLoopSentinel, | ||||||||||||||||||||||
| } from './nonInteractiveCli.js'; | ||||||||||||||||||||||
| import { vi, type Mock, type MockInstance } from 'vitest'; | ||||||||||||||||||||||
| import * as fs from 'node:fs/promises'; | ||||||||||||||||||||||
| import * as os from 'node:os'; | ||||||||||||||||||||||
|
|
@@ -73,6 +80,73 @@ vi.mock('./services/CommandService.js', () => ({ | |||||||||||||||||||||
| }, | ||||||||||||||||||||||
| })); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| describe('skipHeadlessLoopSentinel', () => { | ||||||||||||||||||||||
| it('deletes a recurring session loop.md sentinel job so sessionSize reaches 0', () => { | ||||||||||||||||||||||
| // A recurring SESSION (non-durable) loop.md job left in the scheduler keeps | ||||||||||||||||||||||
| // sessionSize > 0, so the headless hold-open never resolves and the run | ||||||||||||||||||||||
| // hangs. Skipping the sentinel must delete the job, not just no-op the tick. | ||||||||||||||||||||||
| const scheduler = new CronScheduler(); | ||||||||||||||||||||||
| const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_CRON, true); | ||||||||||||||||||||||
|
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] All three Add a mirror test using it('deletes a recurring session dynamic-sentinel job', () => {
const scheduler = new CronScheduler();
const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_DYNAMIC, true);
expect(scheduler.sessionSize).toBe(1);
expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true);
expect(scheduler.sessionSize).toBe(0);
});— qwen3.7-max 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] All four
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||
| expect(scheduler.sessionSize).toBe(1); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(scheduler.sessionSize).toBe(0); | ||||||||||||||||||||||
| expect(scheduler.list()).toHaveLength(0); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| it('also cleans up a recurring session job for the dynamic sentinel', () => { | ||||||||||||||||||||||
| // Mirror of the cron case for `<<loop.md-dynamic>>`. skipHeadlessLoopSentinel | ||||||||||||||||||||||
| // must route through detectLoopSentinel (which matches BOTH sentinels), not a | ||||||||||||||||||||||
| // `=== LOOP_SENTINEL_CRON` comparison — otherwise a dynamic loop.md job would | ||||||||||||||||||||||
| // pin sessionSize > 0 and hang the headless run. | ||||||||||||||||||||||
| const scheduler = new CronScheduler(); | ||||||||||||||||||||||
| const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_DYNAMIC, true); | ||||||||||||||||||||||
| expect(scheduler.sessionSize).toBe(1); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(scheduler.sessionSize).toBe(0); | ||||||||||||||||||||||
| expect(scheduler.list()).toHaveLength(0); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| it('returns false and keeps a non-sentinel job', () => { | ||||||||||||||||||||||
| const scheduler = new CronScheduler(); | ||||||||||||||||||||||
| scheduler.create('*/5 * * * *', 'do real work', true); | ||||||||||||||||||||||
| const job = scheduler.list()[0] as CronJob; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(false); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(scheduler.sessionSize).toBe(1); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| it('does not delete a durable sentinel job (it persists for a future session)', () => { | ||||||||||||||||||||||
| // Durable jobs live under ~/.qwen and never count toward sessionSize, so | ||||||||||||||||||||||
| // they don't pin the run; deleting one would wrongly remove it from disk. | ||||||||||||||||||||||
| const scheduler = new CronScheduler(); | ||||||||||||||||||||||
| const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_CRON, true); | ||||||||||||||||||||||
| job.durable = true; | ||||||||||||||||||||||
| const deleteSpy = vi.spyOn(scheduler, 'delete'); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(deleteSpy).not.toHaveBeenCalled(); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| it('does not delete a non-recurring sentinel job (one-shot stays in the scheduler)', () => { | ||||||||||||||||||||||
| // The deletion branch requires BOTH `recurring && !durable`. A one-shot | ||||||||||||||||||||||
| // sentinel job is already removed by the scheduler before it fires, so this | ||||||||||||||||||||||
| // guard must NOT delete it — a `!durable`-only guard would wrongly evict it. | ||||||||||||||||||||||
| const scheduler = new CronScheduler(); | ||||||||||||||||||||||
| const job = scheduler.create('*/5 * * * *', LOOP_SENTINEL_CRON, false); | ||||||||||||||||||||||
| const deleteSpy = vi.spyOn(scheduler, 'delete'); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(skipHeadlessLoopSentinel(scheduler, job)).toBe(true); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(deleteSpy).not.toHaveBeenCalled(); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| describe('runNonInteractive', () => { | ||||||||||||||||||||||
| let mockConfig: Config; | ||||||||||||||||||||||
| let mockSettings: LoadedSettings; | ||||||||||||||||||||||
|
|
@@ -3137,6 +3211,42 @@ describe('runNonInteractive', () => { | |||||||||||||||||||||
| ); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| it('installs a skipDurableFire predicate that classifies loop.md sentinels in headless mode', async () => { | ||||||||||||||||||||||
| // Locks the wiring at the scheduler-enable site: runNonInteractive must | ||||||||||||||||||||||
| // hand the scheduler a predicate that skips durable loop.md sentinels | ||||||||||||||||||||||
| // (which a headless run can't expand), while still letting non-sentinel | ||||||||||||||||||||||
| // durable jobs fire. Both halves are covered alone — detectLoopSentinel via | ||||||||||||||||||||||
| // skipHeadlessLoopSentinel above, the filter via cronScheduler tests — but | ||||||||||||||||||||||
| // nothing pins that runNonInteractive actually connects them. A refactor | ||||||||||||||||||||||
| // dropping or rewriting this call would otherwise silently fire raw | ||||||||||||||||||||||
| // `<<loop.md>>` sentinels at the model (or skip real durable jobs), uncaught. | ||||||||||||||||||||||
| setupMetricsMock(); | ||||||||||||||||||||||
| // Real scheduler with no projectRoot: enableDurable() short-circuits (no | ||||||||||||||||||||||
| // filesystem/lock work) and, with no jobs, the headless cron hold-open | ||||||||||||||||||||||
| // resolves immediately, so runNonInteractive returns without hanging. | ||||||||||||||||||||||
| const scheduler = new CronScheduler(); | ||||||||||||||||||||||
| const skipSpy = vi.spyOn(scheduler, 'setSkipDurableFire'); | ||||||||||||||||||||||
| mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); | ||||||||||||||||||||||
| mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); | ||||||||||||||||||||||
| mockGeminiClient.sendMessageStream.mockReturnValue( | ||||||||||||||||||||||
| createStreamFromEvents([ | ||||||||||||||||||||||
| { type: GeminiEventType.Content, value: 'ok' }, | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| type: GeminiEventType.Finished, | ||||||||||||||||||||||
| value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| ]), | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| await runNonInteractive(mockConfig, mockSettings, 'test', 'p-cron-wiring'); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| expect(skipSpy).toHaveBeenCalledOnce(); | ||||||||||||||||||||||
| const predicate = skipSpy.mock.calls[0][0]; | ||||||||||||||||||||||
| expect(predicate({ prompt: LOOP_SENTINEL_CRON } as CronJob)).toBe(true); | ||||||||||||||||||||||
| expect(predicate({ prompt: LOOP_SENTINEL_DYNAMIC } as CronJob)).toBe(true); | ||||||||||||||||||||||
| expect(predicate({ prompt: 'regular cron job' } as CronJob)).toBe(false); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| describe('--json-schema structured output', () => { | ||||||||||||||||||||||
| // Helper: walk an emitted event and extract the first tool_use_id when | ||||||||||||||||||||||
| // it represents a tool_result block. Returns undefined for any other | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
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.
[Suggestion]
TRANSIENT_FS_CODESis missing'EISDIR'and'ENOTDIR'.readLoopTaskFileperforms a pre-openlstat()on the project candidate. If the path is replaced with a directory (or a non-directory) between thelstatand the subsequentfs.open,fs.openthrowsEISDIRorENOTDIR. Neither code is in the transient whitelist, so the error propagates and permanently terminates the dynamic loop instead of degrading to a no-op tick.This is a narrow TOCTOU race but the fix is trivial:
— qwen3.7-max via Qwen Code /review