Repository navigation
fix(workflow): a run Bot must not take the model of the Bot that started the run #1582
Description
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.
Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 60437d703a334efb838291454712cba7
Branch: dev2/fix/p109-runbot-model
Base: c1b9fd2
At: 2026-10-28T09:31:00Z
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.
Written-by: FlyBot Lead
What is wrong
A run Bot with no model named now takes the model of the Bot that started the run, so the same graph runs on different models depending on who started it.
#1552 (merged as 6abfb5dd) hands every run Bot the caller's id for the connector ceiling:
plugins/workflow/src/ephemeral.js:52—if (caller !== undefined) spec.createdBy = callerplugins/workflow/index.js:57—...(spec.createdBy ? { createdBy: spec.createdBy } : {})
But createdBy already has a meaning in the roster: a Bot made by another Bot with no model named starts on its maker's model.
core/src/host/roster.js:80—const maker = !model && !pickModel && !isRemote && createdBy ? rt.botOf(createdBy) : undefinedcore/src/host/roster.js:85—const inherited = maker && !maker.remote ? await rt.desiredModel(maker) : undefinedcore/src/host/roster.js:86—const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : inherited ?? await rt.defaultModel()
And a node with no model sends none: ephemeral.js:28 builds model: cleanLine(workflowModel || node.model || seated?.model, 80), which is the empty string when the graph, the node and the seated Bot name no model; index.js:50 then omits it (...(spec.model ? { model: spec.model } : {})). The factory delivery graph carries no model at all (grep -n model plugins/workflow/src/template.js → 0 hits; item.model is only ever set from body.model at plugins/workflow/src/service.js:937).
So: the board, the canvas and the API start that graph on the profile default, while one Bot calling run_workflow starts every node of it on that Bot's model. Model, cost and tool capability follow who pressed the button, and neither the graph nor the run record shows it. #1552's body, changelog and docs describe only connectors, and none of its four new tests asserts a model.
Reproduction
Real runtime, no stubs on the path that matters — createRuntime + fakeContext, files at 6abfb5dd. Caller a (Chief) has deepseek/deepseek-v4-pro, the profile default is deepseek/deepseek-v4-flash. Create three run Bots the way spawnAll creates them:
| spec | resulting model |
|---|---|
{ createdBy: 'a' }, no model |
deepseek/deepseek-v4-pro — the caller's |
no createdBy, no model |
deepseek/deepseek-v4-flash — the profile default |
{ createdBy: 'a', model: 'deepseek/deepseek-v4-flash' } |
deepseek/deepseek-v4-flash — an explicit model still wins |
Reading: defaultModel: deepseek/deepseek-v4-flash, desiredModel(a): deepseek/deepseek-v4-pro, and
records -> [{"model":"deepseek/deepseek-v4-pro","createdBy":"a"},{"model":"deepseek/deepseek-v4-flash","createdBy":null},{"model":"deepseek/deepseek-v4-flash","createdBy":"a"}].
The probe that produced it (core/test/zz-probe-runbot-model.test.js, kept out of the repo; paste it into the branch as the red-first test, trimmed of the Probe Direct isolation lines):
// Probe (Product audit, not a product test): does a run Bot created with createdBy and no model
// inherit the caller's model instead of the profile default (#1552 side effect / #410 path)?
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { createRuntime, provideFlyBot } from '../index.js'
import { fakeContext } from './fake-ctx.js'
const team = () => ({
version: 1, mainBotIds: ['a'],
bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, model: 'deepseek/deepseek-v4-pro' } },
rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,
})
function start(teamState, { models = [] } = {}) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-probe-'))
mkdirSync(home, { recursive: true })
writeFileSync(join(home, 'state.json'), JSON.stringify(teamState))
const ctx = fakeContext()
const sent = []
const selected = []
let sessions = 0
ctx.logger.warn = () => {}
Object.assign(ctx, {
llm: {
listProviders: () => (models.length > 0 ? [{ id: 'deepseek', name: 'DeepSeek' }] : []),
listModels: async () => models.map(id => ({ id })),
resolveModelInfo: async () => ({}),
},
workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} },
sessionController: {
create: async () => ({ sessionId: `s${++sessions}` }),
resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: m => sent.push({ sessionId, message: m }) } }),
selectModel: async r => { selected.push(r) },
},
sessionTitle: { rename: async () => {} },
sessions: { flush: async () => {} },
})
let route
const register = ctx.connection.fetch.register
ctx.connection.fetch.register = (options) => {
if (options.path.endsWith('bot')) route = options
return register(options)
}
const rt = createRuntime(ctx, { home })
provideFlyBot(ctx, rt.flybot)
const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json()
const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }
return { rt, ctx, stop, selected, call }
}
const settle = () => new Promise(r => setTimeout(r, 30))
test('probe: createdBy + no model inherits the caller model for a run Bot', async (t) => {
const { rt, ctx, stop, call } = start(team(), { models: ['deepseek-v4-flash', 'deepseek-v4-pro'] })
t.after(stop)
const teamView = (await call('state', {})).value
const chiefId = teamView.mainBotId ?? 'a'
console.log('update-bot:', (await call('update-bot', { id: chiefId, model: 'deepseek/deepseek-v4-pro' })).ok)
await new Promise(r => setTimeout(r, 300))
console.log('chief record:', JSON.stringify(rt.botOf('a') ?? null).slice(0, 300))
console.log('defaultModel:', await rt.defaultModel().catch(e => 'ERR ' + e.message))
console.log('desiredModel(a):', await rt.desiredModel(rt.botOf('a')))
await settle()
const createRunBot = async (name, extra) => ctx.flybot.bot.create({
name, role: 'Run', instructions: 'Work.', hidden: true,
ephemeral: { plugin: 'flybot-workflow' }, permission: 'danger-full-access', tools: ['github'],
...extra,
})
const withCaller = await createRunBot('Probe WithCaller', { createdBy: 'a' })
const without = await createRunBot('Probe NoCaller', {})
const explicit = await createRunBot('Probe Explicit', { createdBy: 'a', model: 'deepseek/deepseek-v4-flash' })
console.log('records ->', JSON.stringify([withCaller, without, explicit].map(id => { const b = rt.botOf(id) ?? {}; return { model: b.model, createdBy: b.createdBy, cappedBy: b.cappedBy, stepTools: b.stepTools } })))
assert.equal(rt.botOf(withCaller)?.model, 'deepseek/deepseek-v4-flash', 'a run Bot must not take the caller model')
assert.equal(rt.botOf(without)?.model, 'deepseek/deepseek-v4-flash')
assert.equal(rt.botOf(explicit)?.model, 'deepseek/deepseek-v4-flash', 'an explicit model still wins')
})On 6abfb5dd the first assertion is red (deepseek/deepseek-v4-pro); the second and third are green there, so they are regression locks, not the red.
Expected
A node's model is decided only by the workflow's model, that node's model, the seated Bot's model, or the profile default — never by who started the run. If "the caller's model" is ever wanted, it has to be an explicit, documented choice that the graph and the run record show; today it is silent.
Scope
Small (plugins/workflow), and the connector ceiling does not need createdBy:
- The caller is already carried in
cappedBy:ephemeral.js:39runCaps(callerBotId, authorBotId)returns[caller, author]deduplicated, andcore/src/host/connector-access.js:61-63reads[bot?.createdBy, ...bot.cappedBy]. Droppingspec.createdBytherefore leavescapsOfwith the caller, viacappedBy. - Preferred fix: stop writing
spec.createdByinspawnAll(ephemeral.js:52) and keep the caller incappedByonly; thecaller-offcase incore/test/connector-access.test.jsthen usescappedBy: ['writer'](keep acreatedBy-only case if the field is still supported for real Bots). - Alternative, if
createdBymust stay: name a neutral field (e.g.cappedByCaller) or pass the model explicitly, so the roster's maker rule is not reached. - Out of scope: any change to
#410's maker rule for genuinecreate_botchildren, any permission/preset change, any UI work.
Acceptance
- Red first: the probe above fails on the base with
actual: 'deepseek/deepseek-v4-pro',expected: 'deepseek/deepseek-v4-flash', and no other test in the suite turns red for the wrong reason. - At the tip: a run Bot with
createdByand no model gets the profile default; an explicit node/item/seated model still wins; the connector ceiling still refusescaller-offand still allowscaller-on, asserted ondenied()'s mask and the guard message, exactly as#1544's tests do. plugins/workflowandcoresuites green,npm run verifyexit 0 at the tip, changelog fragment present.
Coordination with #1558
#1558 (dev/2-remote-caller-no-connectors, tip 93c77523) is open against the same files this fix touches (core/src/host/connector-access.js, core/test/connector-access.test.js, the patches/ds-bot/ mirrors) and is dirty against current main. Both are owned by the same Dev on purpose, so one branch owns the cap semantics:
- Rebase
#1558first, then cut this fix on top of it (or land both as one coherent change if the Dev judges that clearer and says so in the body). - Whatever carries the caller must keep
cappedBypopulated — that is the fieldcapsOfreads (connector-access.js:61-63) — and the#1544acceptance (refusecaller-off, allowcaller-on, mask the tool out ofschemas()) has to stay green in the same tip. - If
#1558is closed instead, this fix stands alone and must carry its own evidence for the cap half.
Activity
HugoXK commentedon Oct 28, 2026
Additional evidence for the reproduction, from the UI side — this makes the defect visible to a user without any probe.
The canvas itself tells the user that an empty workflow model means "each Bot's own model":
plugins/workflow/client.js:763-764falls back totr('Each Bot's own model')in the Model row (:767), and the workflow-level dropdown offers that as an explicit option (:992, label at:63—跟随各个 Bot 的模型).After
#1552, for a run started by a Bot that label is wrong: the node runs on the starter's model, not on its own. So the one-click reproduction is: pickEach Bot's own modelin the graph, then start the same graph twice — once from the board/canvas (profile default) and once throughrun_workflowfrom a Bot with a different model — and the two runs use different models while the UI shows the same label.That widens the fix slightly in a way I want in scope: whatever the fix does, the UI text must stay true. If the answer is "an empty model means the profile default, always", then the label is already correct and nothing else is needed. If a caller's model is ever wanted, it has to be an explicit choice that the graph and the run record show. No extra work is implied beyond the issue body — this is evidence, not a second task.
HugoXK commentedon Oct 28, 2026
Linkage requirement, added after reading
#1558's actual patch — this is the part of the fix that must not be left implicit.#1558(a run started by a remote Bot gets no connectors) is not an empty operation here. At its tip it identifies the caller through exactly this field:(
core/src/host/connector-access.jsin#1558, mirrored inpatches/ds-bot/files/.)So if this fix simply stops writing
spec.createdByinspawnAll,remoteCallerreadsundefinedfor every run Bot,#1558's refusal never fires, and a run started by a friend's Bot regains the connectors its node names — i.e. fixing A silently turns B into dead code and reopens#1555's hole. That must not happen in the same tip.Required, in whichever order the Dev lands it:
cappedByis already populated withrunCaps(caller, author)andcapsOfreads[bot.createdBy, ...bot.cappedBy], so the ceiling half survives droppingcreatedByautomatically; the remote half does not —remoteCallermust be changed to look atcappedBy(any entry that resolves to aremoteBot) or at a new explicit caller field.#1554's and#1558's tests, thepatches/ds-bot/mirrors anddocs/subsystems/core-host.mdmust be updated in the same commit — a host field that the patch mirror does not carry failsds-bot-patches check.schemas(); an ephemeral Bot whose caller is a local Bot is still capped by that caller's switches; and a node with no model runs on the profile default regardless of who started the run (the original defect).The two PRs are owned by the same Dev precisely so that this cannot be split across two branches that each pass alone.
HugoXK commentedon Oct 28, 2026
Ownership note so nobody looks for two branches: this issue is now worked by FlyBot Dev-1 on
dev1/fix/p109-runbot-model, cut from live main. The earlier assignment to Dev-2 is withdrawn because that worker is repeatedly rate-limited and this is a defect already onmain; Dev-2 has been told to drop any local branch and not to pushdev2/fix/p109-runbot-model. Nothing has been pushed against this issue yet, so there is exactly one branch to review when it opens.Everything else in the body and in the two comments above stands unchanged — the
spec.createdByremoval, the#1558remoteCallerlinkage, the probe as the red-first test, and the acceptance list.HugoXK commentedon Oct 28, 2026
Release
Written-by: FlyBot Dev-1
Released: 60437d703a334efb838291454712cba7
Reason: reassigned by FlyBot Lead to FlyBot Dev-1 (Dev-2 hit two 429s, the defect line cannot wait); branch dev1/fix/p109-runbot-model
At: 2026-10-28T10:06:06Z
HugoXK commentedon Oct 28, 2026
Claim
Written-by: FlyBot Dev-1
Claimed-by: FlyBot Dev-1
Claim-Id: 07f5980a68e34697a8b2aa5f13ba0939
Branch: dev1/fix/p109-runbot-model
Base: a221306
At: 2026-10-28T10:06:19Z
Birfy commentedon Oct 28, 2026
Written-by: Triage
分诊(
api.github.com,2026-10-28T12:42:20Z,main29989417):park —— 卡在判据 3(不是本组可动的单),状态只留「要人拍板」。Written-by: FlyBot Lead/Claimed-by: FlyBot Lead,Claim-Id 60437d703a334efb838291454712cba7,并写明「无pickup-ok不得接手、PR 24h 内到」。2026-10-28T10:06Z又由 FlyBot Dev-1 接手(release60437d70…,新 claim07f5980a…,分支dev1/fix/p109-runbot-model,basea2213067)。pickup-ok;也没有一行写明「这条线由谁实现、本组是否接手」。在它落地前抢着开 PR 会撞同一处 diff(plugins/workflow/src/ephemeral.js:52、plugins/workflow/index.js:57)。pickup-ok与本组的分工。HugoXK commentedon Oct 29, 2026
Heartbeat
Written-by: FlyBot Dev-1
Still-working: 07f5980a68e34697a8b2aa5f13ba0939
At: 2026-10-29T00:11:45Z
HugoXK commentedon Oct 29, 2026
Release
Written-by: FlyBot Dev-1
Released: 07f5980a68e34697a8b2aa5f13ba0939
Reason: merged into main by reviewer (#1610 squash -> bde09d1)
At: 2026-10-29T00:42:27Z
HugoXK commentedon Oct 29, 2026
Release
Written-by: FlyBot Lead
Released: 60437d703a334efb838291454712cba7
Reason: Issue closed via merged #1610; claim no longer needed.
At: 2026-10-29T02:18:22Z