You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Five defaults nobody chose on purpose: what eleven August reports look like on 0.2.0-rc.2
#9104
Six weeks ago we filed eleven reports here. What follows is not about whether any of them were closed — in this repo that flag would not tell you anything, in either direction.
What we wanted to know was narrower: do the rows we could still put a line number against still hold on a current build? We read each one back against 0.2.0-rc.2 — the published package as it ships, not the release notes — expecting to strike two or three off, and instead found they hold, and that they hold in one direction. Not one of them needed cleverness to find. Each one is a rule most people would already agree on, sitting next to a line that does the other thing:
a failed lookup resolves to the widest set available, not the narrowest;
a failure that has already reached disk is never reported back to whoever wrote it;
one situation gets three answers, and the quietest one is on the busiest path;
finished work stays in the active set, and nothing tied to its own end removes it;
the same contract is written out twice, by hand, in two packages.
Section 1 writes each of those up with its call site. Every source line quoted anywhere here starts in section 4 and can be checked against the published package.
What this post is. On 2026-09-17 we published #6960: the reports we had filed, the version each fix shipped in, and a second table for rows we could not match to a fix. This is the same table re-run twenty days later, with the basis moved from 0.1.6-alpha.2 to 0.2.0-rc.2. Nothing here is a complaint. It is what came back when we went looking, and we are posting it in case someone else is reading the same lines.
1. What each one turns into, on the receiving end
Each one below is a conditional: if this line is reached, this is what follows from it. We have no telemetry saying how often any of these lines is reached, so there is no number in this post — reading what a line does doesn't need that data, and claiming how often it happens does. Call sites are repeated in section 4. Each one closes with a line of its own — the rule that the line above does not follow. That line is the only judgement in the section; everything before it is what the code does.
Consequences come first for two reasons: it is the order to read them in, and a property survives a refactor while a line number does not.
(a) When the lookup misses, what comes back is the widest set there is. #4025 is one line: const owner = scopeOf(ctx) ?? ctx.root; The miss happens at exactly the moment things have not gone to plan, and the substitute handed back is the root — the broadest scope available, used precisely when we know least about whoever is asking. Nothing is logged at that point either, so while the substitution is happening there is no trace of it. What most of us were taught to expect points the other way. We looked for a note on this path and did not find one. The closest official statement is on a different path — subagent route selection — where "a call that supplies no selection field retains configured or inherited routing because the model made no route choice." There the fallback is a decision, and we are not disputing it. The line above is not that line.
Official source: no entry in .agents/notes covers this path. The nearest official statement is on a different path — implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md:17 — "A call that supplies no selection field retains configured or inherited routing because the model made no route choice."
The rule: when something fails, tighten — do not widen.
(b) The failure that reached disk never reaches whoever wrote it.
In #4549 a tool/call goes down, and the tool/result that should follow it never arrives. The scheduler does know — the failure surfaces at :641-645, where the catch waits for the in-flight work and then throws. The writer is never told. What remains is a call nobody will answer, permanently. The repair that would append the missing result does exist, at :646-647, and it is installed on the abort exit, not on the failure exit. Here the official note and the shipped code part ways. The note for parallel tool execution says that on a terminal scheduler failure "recovery reports uncommitted outcomes as unknown", and its abort-side sentence — "undispatched calls receive synthetic aborted call/result pairs" — is exactly what :646-647 implements. The failure exit carries neither. We are not asking for a scheduler failure to become an ordinary tool result: the note considered that and declined it, with a reason we accept. We are pointing at one sentence in it that has no counterpart in the code.
Official source:implemented/feature/2026-07-10-parallel-tool-call-execution.md:107 — "Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure"; :99 for the abort-side pair; :87 for the alternative the note considered and declined.
The rule: A write failure that already reached disk has to get back to whoever did the writing.
(c) A record that was declined looks exactly like one that never existed. #4910 covers three answers to one situation — a record written by an older version. Two answer loudly: a thrown InvalidUploadIndexError, an invalidReplay. The third, at dsh-subagent:1368, answers return void 0 — the caller receives nothing, nothing is counted, nothing is logged. That one sits on the path people reach most often, so downstream nothing can tell absent from declined. Not every old record needs carrying forward. And the project has said what it wants refusal to look like: in the migration architecture note, conversions that are deliberately unsupported must still "raise the typed migration refusal" rather than permit a blanket skip. return void 0 is not that. The two loud answers in the same report are closer to it.
The rule: the one you drop should leave behind the fact that it was dropped.
(d) Finished work stays in the set every call walks.
In #3994 a job record enters the store at :467, and reaching a terminal state takes nothing out of it. activeJobCount at :550 counts only running and stopping, yet it walks every record in the map — finished ones included — to find those two. list at :494 and both teardown scans at :772 and :792 walk it too. The single store.delete is :780, on owner disposal, so the moment a finished record stops costing anything is set by an unrelated lifecycle. A cost curve follows from this; we left it in section 4, because a curve is a consequence and not the point. The point is written in drop()'s own comment at :778 — "the one visible-set change no per-job record carries" — no boundary exists at the job's own end. The official note on the job seam does name this boundary — a job's owner is the session id, and an observer that sees its job removed by the owner's teardown closes with the terminal projection instead of a failed read. What it does not address is what sits above that boundary: every count, every list and every teardown scan keeps walking finished records until an unrelated lifecycle ends. The boundary is on the record; the cost it leaves behind is not.
The rule: A thing should be released when its own work ends, not when something else exits.
(e) The wording you get depends on which package you landed in.
In #4909 the two delegation paths each spell out what the model must do with finished work. dsh-tool-jobs:257 requires reclaiming it, unconditionally. dsh-tool-subagent:576 gates on backgroundEnabled && continuable and carries no reclaim instruction at all. Two hand-written copies of one intent, in different packages, neither referring to the other — with the divergence sitting in prompt text, which is the one part no caller can read from outside. Drift between them is not a risk that might arrive; it is scheduled. The official note for continuable subagent conversations does name the disposal question — disposing a parent removes the Agent that still owns descendant teardown, and keeping every historical subagent resident "would make memory use unbounded." What it does not name is that the instruction telling the model what to do with finished work is written out twice by hand, and that the two copies disagree. The trade-off is on the record; the drift is not.
These are the five. None of them took any cleverness to find — that is the whole reason they belong in a list rather than in separate reports. If we have the direction of any one of them wrong, that is the correction we would most welcome.
2. What happened to each of the eleven
The column below is not a state field. In this repo a discussion is not closed when the thing in it is fixed, so that flag carries no signal either way. What the column holds is what happened to the thing we reported, read back on 0.2.0-rc.2 — whether the line we pointed at was fixed, partly fixed, or is still there word for word.
a neighbouring-generation migration catalog landed; the three refusals themselves did not change
Three of the eleven moved on this re-check. That is the part we would rather lead with than the eight that did not: #3662 shipped, #3633 got a real lock, #4910 got a migration catalog — and we said so in #6960 when it happened. What is left in the table is what we could still put a line number against on the current build.
3. The same five rules, reported by other people
The five above are not five opinions held by one team. Every row below is somebody else's report, filed under their own name, about their own setup — some weeks before ours, some weeks after, two of them on the same day we filed. We are not quoting them as agreement. We met them because we had commented on their thread, or they had commented on ours, which is roughly what you would expect if one rule is being broken in more than one place.
Rule
Their report
In their words
Filed
(a) a missing value falls back to the widest thing in reach
"a subagent call that omitsprovider/model skips the allowlist entirely and can start a child on a route the user never authorized" — the official note of 08-24 records this as a decision, not an oversight; we list it because the shape is the same, not as something the project missed
"工具执行失败时,agent-loop 应合成一条错误 tool/result(如 isError 消息)落盘,保证 'assistant 的 tool_calls 后面必有 tool messages' 这一 API 不变量" — the same call site and the same missing piece, filed three days before ours
an independent analysis of #4549 and a candidate patch, with regression tests
10-03
One number here is not ours to claim: #6520, the list @PerryLink keeps, opens by saying it converges 73 community discussions. We are not in that count in any special way. It is what happens when the same handful of rules keep getting hit.
What this changes is narrow but real. A rule that two people write down independently, in different words, about different parts of the same system, is not a preference. It is the kind of thing that survives a rewrite — which is also why we wrote the five as rules rather than as complaints.
4. What the newer build still reads like
For the rows we could relocate on 0.2.0-rc.2, these are the lines. Each one is checkable against the published package.
The repair that appends a missing tool/result is wired to the abort exit, not the failure exit
dsh-agent-looplib/index.js:641-645 — the catch waits (await Promise.allSettled(inFlight.values())) and then throw schedulerFailure.error, with no commitReady() and no repair call; the repair loop sits at :646-647 inside if (aborted)
Terminal records are reclaimed on the owner's lifecycle boundary, never on the job reached terminal state boundary
dsh-jobs-locallib/index.js — :467this.store.set(id, job); scans of the whole map at :494:550:772:792, where :550activeJobCount counts only running/stopping yet still walks every record including terminal ones; the only store.delete is :780, reached from owner disposal
The two delegation families give the model different instructions: one requires reclaiming finished work, the other only encourages starting it
dsh-tool-jobs:257 (unconditional, text requires collecting and killing jobs) vs dsh-tool-subagent:576 (gated on backgroundEnabled && continuable, text contains no reclaim instruction)
Two notes here, because neither is ours and both are better sourced than we could manage:
#4549 has an independent analysis and a candidate patch — #8782 (filed 10-03 by @wangpfcn) reads the same call site, names the same missing commitReady(), and ships a branch with regression tests. Anyone who wants the full stack should read that one rather than ours.
The descriptor row has an impact number now, and it does not come from us — @m0nk111's report of 10-04 (#8874, same text also at #8871 and #8868) measured it on a real corpus: 492 session logs on the host, 340 of them refused after upgrading (69%), and not one of them carrying version: 3. The refusal itself is one line, payload-validation.ts:956. We did not run that measurement and cannot vouch for it; we only point out that it lands on the same line we pointed at in August.
5. What we could no longer check — and why that is the actual finding
#3631 and #4795 pointed at dsh-client-runtime and dsh-host-apiproxy. Those two packages do not exist on 0.2.0-rc.2 — they no longer resolve on the registry at that version. So for these two rows we can honestly report only this:
The module our report pointed at has been split or renamed since we filed. We can no longer say whether the behaviour we described is still present, because the thing we were quoting is no longer there to quote.
Packages get reorganised; 0.2.0 reorganised a lot. The part that compounds quietly is this:
the longer a report sits unanswered, the more likely the evidence it carries expires before the answer does. Nothing has to go wrong for this — it is arithmetic on two rates. Past a certain point is this fixed yet stops being answerable from outside at all, and whoever hits it next starts from zero instead of from our row.
6. What we raised, and what shipped
Three rows that shipped, and what we did about each:
#3633 (session lock) — shipped.0.2.0-rc.2 has a real cross-process lease: dsh-session-persistence-jsonl imports a native tryLockExclusive at :11, defines LEASE_FILENAME = "session.lock" at :643, and acquires through acquireWriteLease() at :2932. Our August framing — "no cross-process protection" — is out of date. (One thing we have not established is whether every append path runs inside that lease; the append itself still opens with open(path, "a"). We are flagging that as unanswered, not as a defect.)
#4910 (format migration) — partly shipped, and we said so at the time. A versioned catalog and a historical catalog are both present and branch by version. We appended a correction to #4910 on 09-17 saying exactly this, so nothing above contradicts it; what remains is version skipping and hard refusal on the non-catalogued paths — the narrower rows in section 4.
#3662 — shipped in 0.1.5-rc.2; already marked so in #6960.
7. Four questions, and what we can bring
We are not assuming any of this is news internally — it may well be known and queued. What decides whether we keep tracking it is narrower than that: for each row, is this a bug or is this the design? From outside those two are indistinguishable, and reading the source does not separate them.
Four things, in the order we care about them:
Is the fallback direction in (a) intended?scopeOf(ctx) ?? ctx.root resolves outward to the widest available set when the scope lookup misses. If it is deliberate, the part we lack is the invariant — what a caller should assume once the lookup has missed. As written it is indistinguishable from a lookup that failed silently. Same question for (c): is return void 0 the intended answer for that call site, or for a different one that shares the code?
Who takes a finished job out of the active set? In (d) the answer today is "whatever owns it, once that owner exits" — so the lifetime of a finished record is set by something other than its own end. If these records are meant to accumulate for the whole session and be dropped in one go, that is a coherent reading and we would like to hear it. What we could not find is any boundary defined by the job reaching its terminal state, which is the place ownership is normally handed back.
Section 4: bug, or working as designed? One word per row. Anything you call intended, we stop tracking and stop writing about. Anything left unanswered we will assume is neither, and keep it on this list.
Do you want this list at all? We re-run it because it is cheap on our side and occasionally lands on someone else's question. If it is noise, say so and this one is the last.
What we can bring, for anyone who wants it: we re-run these eleven rows against every new tag and post only the delta, which takes about twenty minutes. We can build a focused repro for a single row on request. And we will take corrections the same way we did on #6960 — several people sent them, and every one of them was right.
Authorship note: run into during day-to-day work and written up by me; root-cause tracing and drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out; I will re-verify against the toolchain and correct.
"a subagent call that omitsprovider/model skips the allowlist entirely and can start a child on a route the user never authorized" —— 官方 08-24 那条记录把它写成了设计,不是遗漏;我们列它只是因为形状相同
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
EN / English
Six weeks ago we filed eleven reports here. What follows is not about whether any of them were closed — in this repo that flag would not tell you anything, in either direction.
What we wanted to know was narrower: do the rows we could still put a line number against still hold on a current build? We read each one back against
0.2.0-rc.2— the published package as it ships, not the release notes — expecting to strike two or three off, and instead found they hold, and that they hold in one direction. Not one of them needed cleverness to find. Each one is a rule most people would already agree on, sitting next to a line that does the other thing:Section 1 writes each of those up with its call site. Every source line quoted anywhere here starts in section 4 and can be checked against the published package.
What this post is. On 2026-09-17 we published #6960: the reports we had filed, the version each fix shipped in, and a second table for rows we could not match to a fix. This is the same table re-run twenty days later, with the basis moved from
0.1.6-alpha.2to0.2.0-rc.2. Nothing here is a complaint. It is what came back when we went looking, and we are posting it in case someone else is reading the same lines.1. What each one turns into, on the receiving end
Each one below is a conditional: if this line is reached, this is what follows from it. We have no telemetry saying how often any of these lines is reached, so there is no number in this post — reading what a line does doesn't need that data, and claiming how often it happens does. Call sites are repeated in section 4. Each one closes with a line of its own — the rule that the line above does not follow. That line is the only judgement in the section; everything before it is what the code does.
Consequences come first for two reasons: it is the order to read them in, and a property survives a refactor while a line number does not.
(a) When the lookup misses, what comes back is the widest set there is.
#4025 is one line:
const owner = scopeOf(ctx) ?? ctx.root;The miss happens at exactly the moment things have not gone to plan, and the substitute handed back is the root — the broadest scope available, used precisely when we know least about whoever is asking. Nothing is logged at that point either, so while the substitution is happening there is no trace of it. What most of us were taught to expect points the other way. We looked for a note on this path and did not find one. The closest official statement is on a different path — subagent route selection — where "a call that supplies no selection field retains configured or inherited routing because the model made no route choice." There the fallback is a decision, and we are not disputing it. The line above is not that line.(b) The failure that reached disk never reaches whoever wrote it.
In #4549 a
tool/callgoes down, and thetool/resultthat should follow it never arrives. The scheduler does know — the failure surfaces at:641-645, where thecatchwaits for the in-flight work and then throws. The writer is never told. What remains is a call nobody will answer, permanently. The repair that would append the missing result does exist, at:646-647, and it is installed on the abort exit, not on the failure exit. Here the official note and the shipped code part ways. The note for parallel tool execution says that on a terminal scheduler failure "recovery reports uncommitted outcomes as unknown", and its abort-side sentence — "undispatched calls receive synthetic aborted call/result pairs" — is exactly what:646-647implements. The failure exit carries neither. We are not asking for a scheduler failure to become an ordinary tool result: the note considered that and declined it, with a reason we accept. We are pointing at one sentence in it that has no counterpart in the code.(c) A record that was declined looks exactly like one that never existed.
#4910 covers three answers to one situation — a record written by an older version. Two answer loudly: a thrown
InvalidUploadIndexError, aninvalidReplay. The third, atdsh-subagent:1368, answersreturn void 0— the caller receives nothing, nothing is counted, nothing is logged. That one sits on the path people reach most often, so downstream nothing can tell absent from declined. Not every old record needs carrying forward. And the project has said what it wants refusal to look like: in the migration architecture note, conversions that are deliberately unsupported must still "raise the typed migration refusal" rather than permit a blanket skip.return void 0is not that. The two loud answers in the same report are closer to it.(d) Finished work stays in the set every call walks.
In #3994 a job record enters the store at
:467, and reaching a terminal state takes nothing out of it.activeJobCountat:550counts onlyrunningandstopping, yet it walks every record in the map — finished ones included — to find those two.listat:494and both teardown scans at:772and:792walk it too. The singlestore.deleteis:780, on owner disposal, so the moment a finished record stops costing anything is set by an unrelated lifecycle. A cost curve follows from this; we left it in section 4, because a curve is a consequence and not the point. The point is written indrop()'s own comment at:778— "the one visible-set change no per-job record carries" — no boundary exists at the job's own end. The official note on the job seam does name this boundary — a job'sowneris the session id, and an observer that sees its job removed by the owner's teardown closes with the terminal projection instead of a failed read. What it does not address is what sits above that boundary: every count, every list and every teardown scan keeps walking finished records until an unrelated lifecycle ends. The boundary is on the record; the cost it leaves behind is not.(e) The wording you get depends on which package you landed in.
In #4909 the two delegation paths each spell out what the model must do with finished work.
dsh-tool-jobs:257requires reclaiming it, unconditionally.dsh-tool-subagent:576gates onbackgroundEnabled && continuableand carries no reclaim instruction at all. Two hand-written copies of one intent, in different packages, neither referring to the other — with the divergence sitting in prompt text, which is the one part no caller can read from outside. Drift between them is not a risk that might arrive; it is scheduled. The official note for continuable subagent conversations does name the disposal question — disposing a parent removes the Agent that still owns descendant teardown, and keeping every historical subagent resident "would make memory use unbounded." What it does not name is that the instruction telling the model what to do with finished work is written out twice by hand, and that the two copies disagree. The trade-off is on the record; the drift is not.These are the five. None of them took any cleverness to find — that is the whole reason they belong in a list rather than in separate reports. If we have the direction of any one of them wrong, that is the correction we would most welcome.
2. What happened to each of the eleven
The column below is not a state field. In this repo a discussion is not closed when the thing in it is fixed, so that flag carries no signal either way. What the column holds is what happened to the thing we reported, read back on
0.2.0-rc.2— whether the line we pointed at was fixed, partly fixed, or is still there word for word.0.2.0-rc.2(see section 5)agent/inbox/splicedsplits log validity from readabilitysession.lock,acquireWriteLease); whether it wraps the append itself is still untested on our side0.1.5-rc.2; we marked it so in #6960scopeOf(ctx) ?? ctx.rootstill there verbatimtool/callThree of the eleven moved on this re-check. That is the part we would rather lead with than the eight that did not: #3662 shipped, #3633 got a real lock, #4910 got a migration catalog — and we said so in #6960 when it happened. What is left in the table is what we could still put a line number against on the current build.
3. The same five rules, reported by other people
The five above are not five opinions held by one team. Every row below is somebody else's report, filed under their own name, about their own setup — some weeks before ours, some weeks after, two of them on the same day we filed. We are not quoting them as agreement. We met them because we had commented on their thread, or they had commented on ours, which is roughly what you would expect if one rule is being broken in more than one place.
subagentcall that omitsprovider/modelskips the allowlist entirely and can start a child on a route the user never authorized" — the official note of 08-24 records this as a decision, not an oversight; we list it because the shape is the same, not as something the project missedtool/result(如 isError 消息)落盘,保证 'assistant 的 tool_calls 后面必有 tool messages' 这一 API 不变量" — the same call site and the same missing piece, filed three days before oursOne number here is not ours to claim: #6520, the list @PerryLink keeps, opens by saying it converges 73 community discussions. We are not in that count in any special way. It is what happens when the same handful of rules keep getting hit.
What this changes is narrow but real. A rule that two people write down independently, in different words, about different parts of the same system, is not a preference. It is the kind of thing that survives a rewrite — which is also why we wrote the five as rules rather than as complaints.
4. What the newer build still reads like
For the rows we could relocate on
0.2.0-rc.2, these are the lines. Each one is checkable against the published package.0.2.0-rc.2still reads the sametool/resultis wired to the abort exit, not the failure exitdsh-agent-looplib/index.js:641-645— thecatchwaits (await Promise.allSettled(inFlight.values())) and thenthrow schedulerFailure.error, with nocommitReady()and no repair call; the repair loop sits at:646-647insideif (aborted)dsh-jobs-locallib/index.js—:467this.store.set(id, job); scans of the whole map at:494:550:772:792, where:550activeJobCountcounts onlyrunning/stoppingyet still walks every record including terminal ones; the onlystore.deleteis:780, reached from owner disposaldsh-mcp-clientlib/index.js:812—const owner = scopeOf(ctx) ?? ctx.root;dsh-subagentlib/index.js:1309SUBAGENT_DESCRIPTOR_VERSION = 3(was2when we filed);:1368if (version !== 3) return void 0;dsh-llm-deepseeklib/index.js:781if (index.formatVersion !== 3 || !Array.isArray(index.records)) throw new InvalidUploadIndexError(...)dsh-llm-pi-ailib/index.js:104if (response["version"] !== 2) return invalidReplay(...)dsh-tool-jobs:257(unconditional, text requires collecting and killing jobs) vsdsh-tool-subagent:576(gated onbackgroundEnabled && continuable, text contains no reclaim instruction)Two notes here, because neither is ours and both are better sourced than we could manage:
commitReady(), and ships a branch with regression tests. Anyone who wants the full stack should read that one rather than ours.version: 3. The refusal itself is one line,payload-validation.ts:956. We did not run that measurement and cannot vouch for it; we only point out that it lands on the same line we pointed at in August.5. What we could no longer check — and why that is the actual finding
#3631 and #4795 pointed at
dsh-client-runtimeanddsh-host-apiproxy. Those two packages do not exist on0.2.0-rc.2— they no longer resolve on the registry at that version. So for these two rows we can honestly report only this:Packages get reorganised; 0.2.0 reorganised a lot. The part that compounds quietly is this:
the longer a report sits unanswered, the more likely the evidence it carries expires before the answer does. Nothing has to go wrong for this — it is arithmetic on two rates. Past a certain point is this fixed yet stops being answerable from outside at all, and whoever hits it next starts from zero instead of from our row.
6. What we raised, and what shipped
Three rows that shipped, and what we did about each:
0.2.0-rc.2has a real cross-process lease:dsh-session-persistence-jsonlimports a nativetryLockExclusiveat:11, definesLEASE_FILENAME = "session.lock"at:643, and acquires throughacquireWriteLease()at:2932. Our August framing — "no cross-process protection" — is out of date. (One thing we have not established is whether every append path runs inside that lease; the append itself still opens withopen(path, "a"). We are flagging that as unanswered, not as a defect.)0.1.5-rc.2; already marked so in #6960.7. Four questions, and what we can bring
We are not assuming any of this is news internally — it may well be known and queued. What decides whether we keep tracking it is narrower than that: for each row, is this a bug or is this the design? From outside those two are indistinguishable, and reading the source does not separate them.
Four things, in the order we care about them:
scopeOf(ctx) ?? ctx.rootresolves outward to the widest available set when the scope lookup misses. If it is deliberate, the part we lack is the invariant — what a caller should assume once the lookup has missed. As written it is indistinguishable from a lookup that failed silently. Same question for (c): isreturn void 0the intended answer for that call site, or for a different one that shares the code?What we can bring, for anyone who wants it: we re-run these eleven rows against every new tag and post only the delta, which takes about twenty minutes. We can build a focused repro for a single row on request. And we will take corrections the same way we did on #6960 — several people sent them, and every one of them was right.
Authorship note: run into during day-to-day work and written up by me; root-cause tracing and drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out; I will re-verify against the toolchain and correct.
Reported by the OfferKuai Team — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/ | Contact: contact@offerkuai.com
中文版 / ZH
六个星期前我们在这里提交了十一份报告。下面要讲的不是它们有没有被关闭 —— 在这个仓库里,那个标记在两个方向上都不说明什么。
我们想知道的比这更窄:留下来还能给出行号的那些行,在当前构建上还成立吗? 我们把每一行重新对着
0.2.0-rc.2读了一遍 —— 读发布出去的那个包本身,不是读 release notes —— 本来准备划掉两三条,结果是它们都成立,而且成立的方向是同一个。没有哪一条是靠聪明找到的。每一条都是一条大家本来就认的规矩,而它旁边那行代码做的恰好是另一件事:第 1 节把每一条连同调用点写出来。全文引用的每一行源码都从第 4 节开始,都可以对着已发布的包核。
这篇是什么。 2026-09-17 我们发过 #6960:我们提交过的报告、每个修复落在哪个版本,以及第二张表 —— 找不到对应修复的那些行。这是同一张表,在二十天后重跑一次,基准从
0.1.6-alpha.2换到0.2.0-rc.2。 这里没有指控任何人。这就是我们去看了一眼之后拿回来的东西,发出来是因为可能还有别人在读同样的几行。1. 落到承受后果的那一端,会变成什么样
下面每一条都是一句条件句:如果这行被走到,从它身上会推出什么。 我们没有任何埋点数据能说明这些行被走到的频率,所以这篇里不会出现数字 —— 读一行代码做了什么不需要那个数据,而声称它多常发生需要。调用点在第 4 节里重新列了一遍。每一节的结尾都有一行独立的字 —— 那行是上面那行代码没有照着做的那条规矩。它是该节里唯一的判断;它之前写的,都是代码实际做的事。
后果放在前面,有两个原因:这是更好的阅读顺序,而且重构之后属性还在、行号已经不在了。
(a) 查表查不到的时候,回来的是能拿到的最宽的那个集合。
#4025 只有一行:
const owner = scopeOf(ctx) ?? ctx.root;缺失发生在事情恰好没按计划走的那一刻,而顶上来的替代值是根 —— 可用范围里最宽的那个作用域,被用在我们对请求者知道得最少的那个瞬间。那个位置同样没有日志,所以这次替换在发生的当下不留痕迹。而大多数人被教的那句话,指的是相反的方向。 官方记录里没有覆盖这条路径的条目;最接近的一条在另一条路径上 —— 子代理模型路由 —— 那里写着*"未提供选择字段的调用保留已配置或继承的路由,因为模型没有做出路由选择"*。那里的回落是一个决定,我们不反对它。上面这行代码不是那条路径。(b) 那条已经落到盘上的失败,永远到不了写下它的那一方那里。
#4549 里,一个
tool/call写了下去,本该跟在它后面的tool/result永远不会来。调度器是知道的 —— 失败在:641-645浮出来,那里的catch等在跑的工作结束,然后抛出。写入方从头到尾不知道。留下的是一条不会有人应答的 call,而且是永久性的。补齐这个缺失的修复确实存在,在:646-647,而它被装在 abort 出口上,不在 failure 出口上。错误落在一个模块里,损伤落在另一个模块里,没有任何东西把这一头带到那一头。 这里官方记录与已发布的代码分开了。 并行工具调用那条记录写明:调度器终态失败时,"恢复会把未提交的结果报为 unknown";而它关于 abort 的那句 —— "未派发的调用会收到合成的 aborted call/result 对" —— 正是:646-647实现的那个东西。failure 出口两样都没有。我们不是要调度失败变成一条普通 tool result:那条记录考虑过并拒绝了,理由我们接受。我们指的是其中在代码里找不到对应物的那一句。(c) 一条被拒收的记录,和一条从来没有过的记录,长得一模一样。
#4910 覆盖了对同一种情况的三种回答 —— 一条由旧版本写下的记录。两种回答得很响:抛
InvalidUploadIndexError、返回invalidReplay。第三种在dsh-subagent:1368,回答是return void 0—— 调用方拿到空,没有计数,没有日志。安静的那一种恰好坐在人们最常走到的路径上,于是从下游看,不在与被拒无法区分。每条旧记录都要被带过来 —— 这不是我们要的东西。 而官方说过拒收该长什么样:迁移架构那条记录写明,被刻意列为不支持的转换仍然必须*"raise the typed migration refusal"*,而不是整批跳过。return void 0不是那个东西。同一份报告里那两种「响」的回答,反而离它更近。(d) 已经结束的工作,留在每一次调用都要走一遍的那个集合里。
#3994 里,job 记录在
:467进 store,到达终态不会让它少掉任何东西。:550的activeJobCount只统计running与stopping,却要把 map 里每一条记录 —— 包括已完成的 —— 都走一遍,才找得到那两个。:494的list、:772与:792那两处回收扫描也一样走。唯一的store.delete在:780,挂在所有者释放上,所以一条已完成的记录什么时候不再产生代价,是由一个毫不相干的生命周期决定的。这里确实有一条随之而来的开销曲线,我们把它留在第 4 节 —— 曲线是推论,不是论点。论点写在drop()自己的注释里(:778):"这是每条 job 记录自己都不携带的那一种可见集变化" —— 在这个 job 自己的终点上,没有任何边界被定义。 job seam 那条官方记录确实点到了这个边界 —— 一个 job 的owner是 session id,而观察到 job 被 owner teardown 移除时,会用终态投影关闭、而不是一次失败读。它没有处理的是这个边界之上的东西:每一次计数、每一次列举、每一次回收扫描,都要把已完成的记录走一遍,一直走到那个毫不相干的生命周期结束。边界被写进了记录,它留下的代价没有。(e) 你拿到的话术,取决于你落进了哪个包。
#4909 里,两条委派路径各自写出了模型该怎么对待已完成的工作。
dsh-tool-jobs:257无条件要求回收;dsh-tool-subagent:576以backgroundEnabled && continuable为门控,文本里没有任何回收指令。这是同一个意图的两份手抄件,放在不同的包里,彼此互不引用 —— 而漂移就落在 prompt 文本上,那正是调用方从外面读不到的部分。它们之间出现漂移不是一种可能会发生的风险,是排好了期的事。 可续子代理那条官方记录确实点到了 dispose 这件事 —— 释放父 Agent 会移除仍拥有子孙拆除权的那个 Agent,而让所有历史子代理常驻又*"会让内存占用无界"*。它没有点到的是:告诉模型该怎么处理已完成工作的那段指令被手写了两遍,而且两遍不一致。取舍被写进了记录,漂移没有。这五条就是这样。没有哪一条是靠聪明找到的 —— 这正是我们认为它们该进一张清单、而不是各写一份报告的全部理由。如果哪一条的方向我们搞错了,那正是我们最希望收到的一类更正。
2. 十一行各自动了没有
下面这一列不是状态字段 —— 这个仓库不会因为一件事修好了就把讨论关掉,所以那个标记在两个方向上都不说明什么。这一列写的是我们报的那件事后来怎么样了,判读基准是
0.2.0-rc.2—— 当初指的那一处,是修了、部分修了,还是一字未改地还在。0.2.0-rc.2上已经不存在(见第 5 节)agent/inbox/spliced把日志合法性与会话可读性分开session.lock、acquireWriteLease);它有没有包住 append 本体,我方未验0.1.5-rc.2带出;我方在 #6960 已标scopeOf(ctx) ?? ctx.root逐字仍在tool/call这一轮里,十一帖有三帖动了。我们宁愿先把这三帖放在前面,而不是先说没动的八帖:#3662 已随版本带出,#3633 拿到了真的锁,#4910 拿到了迁移 catalog —— 当时我们在 #6960 里就写过了。表里剩下的是那些我们在当前构建上还能给出行号的。
3. 同样这五条规矩,别人也各自撞到过
上面那五条不是同一支队伍的五种看法。下面每一行都是别人的报告,署他们自己的名字,讲他们自己的环境 —— 有的比我们早几周,有的比我们晚几周,其中两条和我们同日提交。我们引它们不是因为它们同意我们;我们碰到它们,是因为我们评论过它们的帖,或者它们评论过我们的帖 —— 如果同一条规矩在不止一处被打破,大概就是这个样子。
subagentcall that omitsprovider/modelskips the allowlist entirely and can start a child on a route the user never authorized" —— 官方 08-24 那条记录把它写成了设计,不是遗漏;我们列它只是因为形状相同tool/result(如 isError 消息)落盘,保证 'assistant 的 tool_calls 后面必有 tool messages' 这一 API 不变量" —— 同一个调用点、同一个缺的东西,比我们早三天这里有一个数字不是我们能认领的:#6520,@PerryLink 一直在维护的清单,开头就写着它收敛了 73 个社区讨论。我们在那个数字里没有任何特殊位置。它只是"同一小撮规矩反复被撞到"之后自然会出现的东西。
这件事改变的东西很窄,但是真的:一条规矩,如果两个人在互不知情的情况下、用不同的话、就同一个系统的不同部分各自写下来,那就不是偏好。它是那种能活过一次重写的判断 —— 这也是我们把这五条写成规矩、而不是写成抱怨的原因。
4. 新构建上仍然写着这些内容
以下是我们在
0.2.0-rc.2上能重新定位的那些行,每一条都可以对着已发布的包独立复核。0.2.0-rc.2上仍然这样读的位置tool/result的修复只装在 abort 出口,没有装在 failure 出口dsh-agent-looplib/index.js:641-645——catch里await Promise.allSettled(inFlight.values())之后直接throw schedulerFailure.error,既没有commitReady()也没有修复调用;修复循环在:646-647的if (aborted)分支里dsh-jobs-locallib/index.js——:467this.store.set(id, job);:494:550:772:792四处对整个 map 的扫描,其中:550的activeJobCount只统计running/stopping,却仍要走过包括终态在内的每一条记录;唯一的store.delete在:780,属于所有者释放路径dsh-mcp-clientlib/index.js:812——const owner = scopeOf(ctx) ?? ctx.root;dsh-subagentlib/index.js:1309SUBAGENT_DESCRIPTOR_VERSION = 3(我们提交时是2);:1368if (version !== 3) return void 0;dsh-llm-deepseeklib/index.js:781if (index.formatVersion !== 3 || !Array.isArray(index.records)) throw new InvalidUploadIndexError(...)dsh-llm-pi-ailib/index.js:104if (response["version"] !== 2) return invalidReplay(...)dsh-tool-jobs:257(无条件,文本要求收集并 kill 任务)对dsh-tool-subagent:576(以backgroundEnabled && continuable为条件,文本里没有任何回收指令)这里要提两条旁证,两条都不是我们的,而且来源比我们硬:
commitReady()),并且带了分支和回归测试。想看完整链路的人应该看它,而不是我们的。version: 3的有 0 份。做出这个拒收决定的本身只有payload-validation.ts:956一行。我们没有跑过那个测量,无法为它背书;我们只指出它落在我们 8 月指的同一行上。5. 我们已经核不动的那部分 —— 以及为什么那才是真正的发现
#3631 和 #4795 指的位置是
dsh-client-runtime和dsh-host-apiproxy。这两个包在0.2.0-rc.2上不存在 —— 该版本下已从 registry 上解析不到。所以这两行我们能如实说的只有:包会被重构;0.2.0 显然重构了很多。但这里有一个会自己悄悄累加的东西:
一份报告被放在那里越久,它自带的证据就越可能在答案到来之前先过期。 这件事不需要谁出错 —— 它只是两个速率之间的算术。过了某个点之后,"这修好了没有"从外部就根本答不上来了,于是下一个撞上它的人不是从我们这行开始,而是从零开始。
6. 我们提过的那些,以及它们的处理情况
三行已经落地的,以及我们当时各自的处理:
0.2.0-rc.2里有真正的跨进程租约:dsh-session-persistence-jsonl在:11引入了原生的tryLockExclusive,:643定义LEASE_FILENAME = "session.lock",:2932的acquireWriteLease()负责获取。我们 8 月的表述 ——"没有跨进程保护"—— 已经过时。(有一件事我们没有证实:是否每一条追加路径都运行在这个租约之内;追加本身仍然是open(path, "a")。这一点我们标为未回答,不是标为缺陷。)0.1.5-rc.2;#6960 里早已如此标记。7. 四个问题,以及我们能做的事
我们不假设这些东西内部还不知道 —— 很可能早就知道并且排上了。真正决定我们还跟不跟的,比这个更窄:每一行,它到底是 bug,还是它本来就该这样? 这两样从外面看长得一模一样,光读源码分不出来。
按我们在意的顺序,是四件事:
scopeOf(ctx) ?? ctx.root在作用域查不到的时候,向外解析到可用范围里最宽的那个集合。如果这是刻意的,那我们缺的那一半就是那条不变式:调用方在查不到的时候应该假定什么。 按现在这个写法,它和一个静默失败的查表从外面无法区分。(c) 是同一个问题:版本不匹配时return void 0—— 这是这个调用点的预期答案,还是另一个共用这段代码的调用点的预期答案?我们能做的,谁用得上就算谁的:每出一个新 tag,我们就把这十一行重跑一遍、只发增量,跑一遍大概二十分钟。你们指定单行的话,我们可以写一份聚焦复现。更正我们照单全收,#6960 那次是这样 —— 当时有几位确实是对的。
协作说明:本文由我在日常使用中撞到并记录成文;根因与成文由 AI 协助完成,我负责校验与发布。我非工程背景 —— 若任何技术表述有误,请直接指出,我会对照工具链重新核实并更正。
本报告由 OfferKuai(Offer快)团队 提交 —— 一家专注 AI 全流程求职托管的创业团队,核心理念是「用户要的是结果,不是重复对话」。创始人:Zhaofeng(Yaming)。我们将 DeepSeek Harness 用于日常开发工作流,这份报告是我们对社区的回馈。官网:https://www.offerkuai.com/ | 联系:contact@offerkuai.com
All reactions