@wjarka/cezarion 0.14.5 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +4 -1
  2. package/dist/automations/types.d.ts +2 -0
  3. package/dist/contract/automations.d.ts +8 -0
  4. package/dist/contract/delegation.d.ts +1 -0
  5. package/dist/contract/index.js +5 -1
  6. package/dist/contract/runs.d.ts +7 -0
  7. package/dist/contract/skills.d.ts +1 -0
  8. package/dist/contract/workflows.d.ts +6 -0
  9. package/dist/delegation/cli.js +5 -3
  10. package/dist/delegation/cli.js.map +1 -1
  11. package/dist/delegation/execution-identity.d.ts +92 -4
  12. package/dist/delegation/execution-identity.js +40 -10
  13. package/dist/delegation/execution-identity.js.map +1 -1
  14. package/dist/delegation/provision.js +5 -2
  15. package/dist/delegation/provision.js.map +1 -1
  16. package/dist/delegation/routes.d.ts +1 -0
  17. package/dist/delegation/service.js +95 -13
  18. package/dist/delegation/service.js.map +1 -1
  19. package/dist/delegation/transport.d.ts +1 -0
  20. package/dist/runs/store.d.ts +1 -0
  21. package/dist/server/server.d.ts +68 -0
  22. package/dist/workflows/load.d.ts +2 -0
  23. package/dist/workflows/load.js +5 -0
  24. package/dist/workflows/load.js.map +1 -1
  25. package/dist/workflows/run.d.ts +37 -2
  26. package/dist/workflows/run.js +160 -47
  27. package/dist/workflows/run.js.map +1 -1
  28. package/dist/workflows/types.d.ts +5 -1
  29. package/dist/workflows/types.js +6 -2
  30. package/dist/workflows/types.js.map +1 -1
  31. package/package.json +3 -3
  32. package/web/dist/assets/{agent-accounts-4EqNShyp.js → agent-accounts-lP_xK83o.js} +1 -1
  33. package/web/dist/assets/{alert-dialog-CVF_qvQ8.js → alert-dialog-CmDuefYh.js} +1 -1
  34. package/web/dist/assets/{collapsible-DVDGHZAE.js → collapsible-BqX8REmY.js} +1 -1
  35. package/web/dist/assets/{commit-list-BuNsBn8P.js → commit-list-BWvv1H7A.js} +1 -1
  36. package/web/dist/assets/{compare-variants-BnQ1O8Ix.js → compare-variants-Bg7eLW3z.js} +1 -1
  37. package/web/dist/assets/{diff-DWmKlqw52.js → diff-0GBum40Q2.js} +2 -2
  38. package/web/dist/assets/{diff-controls-CuqRa-63.js → diff-controls-HwWHHait.js} +1 -1
  39. package/web/dist/assets/{diff-stat-BXriPIds.js → diff-stat-mNkhAc5m.js} +1 -1
  40. package/web/dist/assets/{diff-view-DLPDcgEV.js → diff-view-DW64doDY.js} +1 -1
  41. package/web/dist/assets/{dropdown-menu-IsTcumXE.js → dropdown-menu-sZjYPcLh.js} +1 -1
  42. package/web/dist/assets/{git-toolbar-BjMzETip.js → git-toolbar-DBW5EIcY.js} +1 -1
  43. package/web/dist/assets/{github-BvowTAuy.js → github-CkZ4hWI5.js} +1 -1
  44. package/web/dist/assets/{image-preview-WXKTY8KT.js → image-preview-DZicGNkU.js} +1 -1
  45. package/web/dist/assets/{index-Cz3N9b18.js → index-DWWI-B9n.js} +3 -3
  46. package/web/dist/assets/{markdown-gFxX4dDH.js → markdown-AY2K3kIV.js} +1 -1
  47. package/web/dist/assets/{pill-BlaWNkHk.js → pill-CcJqA1cL.js} +1 -1
  48. package/web/dist/assets/{project-router-CX1jLqj-.js → project-router-SwsXRKXs.js} +1 -1
  49. package/web/dist/assets/{prompt-templates-DwdQcfSc.js → prompt-templates-iV2WD2jy.js} +1 -1
  50. package/web/dist/assets/{repo-git-DnbE8dR5.js → repo-git-BW3TOs3U.js} +1 -1
  51. package/web/dist/assets/{run-diff-Mfc85irR.js → run-diff-DPUNj56q.js} +1 -1
  52. package/web/dist/assets/{run-header-CDO3LV4z.js → run-header-DbDluWoV.js} +1 -1
  53. package/web/dist/assets/{skills-DHfsyXTQ.js → skills-Dv8Vwv5D.js} +1 -1
  54. package/web/dist/assets/{tab-link-DhREnUYE.js → tab-link-DSftvkZS.js} +1 -1
  55. package/web/dist/assets/{task-changes-Cfo-vtEl.js → task-changes-CTCuVls1.js} +1 -1
  56. package/web/dist/assets/{task-commits-CvK4flXT.js → task-commits-weJQ5GSl.js} +1 -1
  57. package/web/dist/assets/{task-files-BwVGUObK.js → task-files-B4bUimY8.js} +1 -1
  58. package/web/dist/assets/{task-thread-g1vc4tKn.js → task-thread-BuXrVB9K.js} +1 -1
  59. package/web/dist/assets/{textarea-DCP8bOOf.js → textarea-CxRdTWlq.js} +1 -1
  60. package/web/dist/assets/{thread-loading-D9DbIsIS.js → thread-loading-JlP6dUpX.js} +1 -1
  61. package/web/dist/assets/{token-metrics-PSnNTJZF.js → token-metrics-Z3UFYbuD.js} +1 -1
  62. package/web/dist/assets/{utils-DNvmfH6b.js → utils-CUnaAifW.js} +1 -1
  63. package/web/dist/assets/{workflows-BDdUJIFT.js → workflows-vA27Yi8W.js} +3 -3
  64. package/web/dist/assets/{zoomable-image-yNqiuVu7.js → zoomable-image-DUE1KFJ0.js} +1 -1
  65. package/web/dist/index.html +13 -13
@@ -1,4 +1,4 @@
1
- import { workerContextHash, captureWorkerAccount, boundWorkerAccountEnv, WorkerIdentityError } from '../delegation/execution-identity.js';
1
+ import { workerContextHash, workerWorkflowHash, workerStepIdentity, captureWorkerAccount, boundWorkerAccountEnv, WorkerIdentityError } from '../delegation/execution-identity.js';
2
2
  import { buildChildEnv } from '../core/agent-env.js';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { spawn } from 'node:child_process';
@@ -796,8 +796,12 @@ export class RunManager {
796
796
  * spawning a CLI. Never throws: an unreadable home degrades to the default profile, which is
797
797
  * exactly the behaviour that predates profiles.
798
798
  */
799
- /** Ordinary runs retain mutable discovery; owned runs require explicit durable evidence. */
800
- workerIdentity(runId) {
799
+ /**
800
+ * Ordinary runs retain mutable discovery; owned runs require explicit durable evidence. With a
801
+ * `stepId`, the step's own pinned entry is checked too (#452): its account must still be
802
+ * available and its model/effort must clear the lock, exactly as the run-level fields must.
803
+ */
804
+ workerIdentity(runId, stepId) {
801
805
  const run = this.store.getRun(runId);
802
806
  if (run?.delegation?.role !== 'worker')
803
807
  return undefined;
@@ -809,6 +813,7 @@ export class RunManager {
809
813
  if (identity.contextHash !== undefined && (!run.delegation.context || workerContextHash(run.delegation.context) !== identity.contextHash)) {
810
814
  throw new WorkerIdentityError('Accepted worker context recipe is unavailable or changed');
811
815
  }
816
+ this.ownedWorkflow(run, identity);
812
817
  if (agentModelsLocked(this.repoRoot) && (identity.model !== undefined || identity.effort !== undefined)) {
813
818
  throw new WorkerIdentityError(`Accepted worker settings cannot run: ${AGENT_MODELS_LOCKED_ERROR}`);
814
819
  }
@@ -816,17 +821,69 @@ export class RunManager {
816
821
  run.model !== identity.model || run.effort !== identity.effort) {
817
822
  throw new WorkerIdentityError('Accepted worker execution identity does not match its run');
818
823
  }
819
- boundWorkerAccountEnv(identity.account, buildChildEnv({ backend: identity.account.provider }));
824
+ // Availability is checked for the account about to be USED: the selected step's entry when
825
+ // there is one, and the single run-level account only for a legacy identity that has no
826
+ // per-step list. A per-step identity checked without a step (the evidence-only calls in
827
+ // `execute`, `runContinuation` and `ownedJob`) binds nothing here — the launch resolves its
828
+ // step and binds that account — so a first-step login removed after its step finished never
829
+ // stops a later step, a Continue or a recovery whose own account is intact (#465).
830
+ const step = stepId === undefined ? undefined : workerStepIdentity(identity, stepId);
831
+ if (step && agentModelsLocked(this.repoRoot) && (step.model !== undefined || step.effort !== undefined)) {
832
+ throw new WorkerIdentityError(`Accepted worker settings cannot run: ${AGENT_MODELS_LOCKED_ERROR}`);
833
+ }
834
+ const account = step?.account ?? (identity.steps === undefined ? identity.account : undefined);
835
+ if (account)
836
+ boundWorkerAccountEnv(account, buildChildEnv({ backend: account.provider }));
820
837
  return identity;
821
838
  }
839
+ /** The pinned identity one agent step of an owned worker runs under; undefined for ordinary and internal runs. */
840
+ workerStep(runId, stepId) {
841
+ const identity = this.workerIdentity(runId, stepId);
842
+ if (!identity)
843
+ return undefined;
844
+ return stepId === undefined
845
+ ? { account: identity.account, grants: identity.grants, ...(identity.model === undefined ? {} : { model: identity.model }), ...(identity.effort === undefined ? {} : { effort: identity.effort }) }
846
+ : workerStepIdentity(identity, stepId);
847
+ }
848
+ /**
849
+ * The steps a continuation may extend. An accepted worker's public `workflowDef` is authority
850
+ * only while its identity binds it: a catalog chain through `ownedWorkflow`, and the default
851
+ * quick-task from the built-in constant, exactly the definition `ownedJob` launches — a salvaged
852
+ * or edited public record never picks which pinned identity a turn runs under.
853
+ */
854
+ continuationDefSteps(record) {
855
+ if (record?.delegation?.role === 'worker') {
856
+ const identity = this.store.readWorkerIdentity(record.id);
857
+ if (identity?.kind === 'accepted')
858
+ return (this.ownedWorkflow(record, identity) ?? QUICK_TASK_WORKFLOW).steps;
859
+ }
860
+ return record?.workflowDef?.steps;
861
+ }
862
+ /**
863
+ * The definition step a continuation extends: the one owning the resumed session, else the
864
+ * chain's last agent step — a synthetic `continue-N` owner and a fresh-session continuation
865
+ * both extend the run's tail. Shared by `continueRun` (which identity to hold the override
866
+ * against) and `runContinuation` (which tools, model, effort and account the turn gets).
867
+ */
868
+ continuationDefStep(record, sessionId) {
869
+ const defSteps = this.continuationDefSteps(record);
870
+ if (defSteps === undefined)
871
+ return undefined;
872
+ const owningStep = sessionId === undefined ? undefined : record?.steps.find((s) => s.sessionId === sessionId);
873
+ if (sessionId !== undefined && owningStep === undefined)
874
+ return undefined;
875
+ return defSteps.find((s) => s.id === owningStep?.id) ?? [...defSteps].reverse().find((s) => stepKind(s) === 'agent');
876
+ }
822
877
  async agentEnvForStep(runId, backend, options = {}) {
823
- const identity = this.workerIdentity(runId);
824
- if (identity) {
825
- if (backend !== identity.account.provider)
878
+ // An owned worker binds the account its identity pinned for THIS step (#452); a legacy identity
879
+ // answers every step with its single run-level account, so the one-provider rule still holds there.
880
+ const step = this.workerStep(runId, options.stepId);
881
+ if (step) {
882
+ if (backend !== step.account.provider)
826
883
  throw new WorkerIdentityError('Accepted worker provider identity cannot change');
827
884
  const env = this.agentEnv(runId, options.generateFollowups);
828
- return { env: { ...env, ...boundWorkerAccountEnv(identity.account, buildChildEnv({ backend, extraEnv: env })) },
829
- profileId: identity.account.profileId, accountBinding: identity.account };
885
+ return { env: { ...env, ...boundWorkerAccountEnv(step.account, buildChildEnv({ backend, extraEnv: env })) },
886
+ profileId: step.account.profileId, accountBinding: step.account };
830
887
  }
831
888
  const run = this.store.getRun(runId);
832
889
  const profileId = options.recordedProfileId
@@ -867,7 +924,14 @@ export class RunManager {
867
924
  throw new DelegationPolicyError('incompatible_state', 'Parent session settings unavailable');
868
925
  return structuredClone(state.delegationSettings);
869
926
  }
870
- /** Resolve caller selection once, before acceptance; permission grants always come from the parent. */
927
+ /**
928
+ * Resolve one agent step's selection before acceptance; permission grants always come from the
929
+ * parent. Called once per agent step of the accepted chain (#452): a same-backend step inherits
930
+ * the parent's account, model and effort, another runner resolves its own project/default
931
+ * account, and an explicit `agentProfile` names the account for either — an id the registry
932
+ * does not know is refused rather than degraded to the default, because a worker silently
933
+ * running on another login is a billing boundary crossed without anyone choosing it.
934
+ */
871
935
  async selectDelegationExecutionSettings(runId, selection) {
872
936
  const parent = this.delegationExecutionSettings(runId);
873
937
  const runner = selection.backend ?? parent.runner;
@@ -880,8 +944,11 @@ export class RunManager {
880
944
  }
881
945
  let accountBinding = parent.accountBinding;
882
946
  let env = buildChildEnv({ backend: runner });
883
- if (!sameBackend) {
884
- const resolved = await resolveProfileEnvForRoot(this.repoRoot, runner);
947
+ if (!sameBackend || selection.agentProfile !== undefined) {
948
+ const resolved = await resolveProfileEnvForRoot(this.repoRoot, runner, selection.agentProfile);
949
+ if (selection.agentProfile !== undefined && resolved.profile.id !== selection.agentProfile) {
950
+ throw new Error(`Unknown ${runner} account "${selection.agentProfile}"`);
951
+ }
885
952
  env = buildChildEnv({ backend: runner, extraEnv: resolved.env });
886
953
  accountBinding = captureWorkerAccount(resolved.profile, env);
887
954
  }
@@ -989,7 +1056,7 @@ export class RunManager {
989
1056
  const grants = this.workerIdentity(run.id)?.grants
990
1057
  ?? run.workflowDef?.steps.find(step => step.id === QUICK_TASK_WORKFLOW.steps[0].id);
991
1058
  return {
992
- workflow: { ...QUICK_TASK_WORKFLOW, steps: [{ ...QUICK_TASK_WORKFLOW.steps[0],
1059
+ workflow: this.ownedWorkflow(run, this.store.readWorkerIdentity(run.id)) ?? { ...QUICK_TASK_WORKFLOW, steps: [{ ...QUICK_TASK_WORKFLOW.steps[0],
993
1060
  ...(grants?.allowedTools === undefined ? {} : { allowedTools: [...grants.allowedTools] }),
994
1061
  ...(grants?.bashAllowlist === undefined ? {} : { bashAllowlist: [...grants.bashAllowlist] }), }] },
995
1062
  input: {
@@ -999,6 +1066,20 @@ export class RunManager {
999
1066
  },
1000
1067
  };
1001
1068
  }
1069
+ /**
1070
+ * The catalog chain an owned worker runs (#451), or undefined for the built-in quick-task.
1071
+ * The public `workflowDef` is mutable state, so it is authority only while the private
1072
+ * identity's hash binds it — the same shape as the context recipe. A tampered or lost
1073
+ * definition refuses execution instead of quietly running quick-task or the edit.
1074
+ */
1075
+ ownedWorkflow(run, identity) {
1076
+ if (identity?.workflowHash === undefined)
1077
+ return undefined;
1078
+ if (!run.workflowDef || workerWorkflowHash(run.workflowDef) !== identity.workflowHash) {
1079
+ throw new WorkerIdentityError('Accepted worker workflow definition is unavailable or changed');
1080
+ }
1081
+ return run.workflowDef;
1082
+ }
1002
1083
  /**
1003
1084
  * Parallel variants (spec 010): N runs of the same workflow on the same
1004
1085
  * task, sharing a groupId. Variant A gets the task verbatim; B and C get a
@@ -1240,10 +1321,23 @@ export class RunManager {
1240
1321
  ? [...run.steps].reverse().find((step) => step.id !== queuedContinuation.id && step.sessionId)
1241
1322
  : undefined;
1242
1323
  if (queuedContinuation && sessionStep?.sessionId) {
1243
- const backend = run.runner ?? 'claude';
1324
+ // An accepted worker's continuation extends the step owning the resumed session, so its
1325
+ // runner and account come from that step's pinned identity (#452), never the run-level
1326
+ // first-step fields — those would re-queue a claude review's Continue as a codex turn with
1327
+ // no session to resume (#465). Broken identity evidence is left for the launch to report.
1328
+ let resumeIdentity;
1329
+ try {
1330
+ resumeIdentity = this.workerStep(run.id, this.continuationDefStep(run, sessionStep.sessionId)?.id);
1331
+ }
1332
+ catch (error) {
1333
+ if (!(error instanceof WorkerIdentityError))
1334
+ throw error;
1335
+ }
1336
+ const backend = resumeIdentity?.account.provider ?? run.runner ?? 'claude';
1244
1337
  const sessionBackend = sessionStep.backend ?? backend;
1245
1338
  const sessionAccount = sessionStep.profileId ?? DEFAULT_AGENT_ACCOUNT_ID;
1246
- const sameAccount = run.agentProfile === undefined || run.agentProfile === sessionAccount;
1339
+ const agentProfile = resumeIdentity?.account.profileId ?? run.agentProfile;
1340
+ const sameAccount = agentProfile === undefined || agentProfile === sessionAccount;
1247
1341
  this.pendingContinuations.set(run.id, {
1248
1342
  stepId: queuedContinuation.id,
1249
1343
  sessionId: sessionBackend === backend && sameAccount ? sessionStep.sessionId : undefined,
@@ -3270,12 +3364,16 @@ export class RunManager {
3270
3364
  if (run.delegation?.role === 'worker' && this.store.getRun(run.delegation.parentRunId)?.status === 'review') {
3271
3365
  return { ok: false, error: 'Continue the reviewing parent before continuing its worker' };
3272
3366
  }
3367
+ // Held against the identity of the step this continuation extends (#452), which for a mixed
3368
+ // chain is not the run-level (first-step) one; `runContinuation` resolves the same step.
3369
+ let resumeIdentity;
3273
3370
  try {
3274
- const identity = this.workerIdentity(runId);
3275
- if (identity && ((opts.runner !== undefined && opts.runner !== identity.account.provider) ||
3276
- (opts.agentProfile !== undefined && opts.agentProfile !== identity.account.profileId) ||
3277
- (opts.model !== undefined && opts.model !== identity.model) ||
3278
- (opts.effort !== undefined && opts.effort !== identity.effort))) {
3371
+ const resumeSession = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
3372
+ resumeIdentity = this.workerStep(runId, this.continuationDefStep(run, resumeSession)?.id);
3373
+ if (resumeIdentity && ((opts.runner !== undefined && opts.runner !== resumeIdentity.account.provider) ||
3374
+ (opts.agentProfile !== undefined && opts.agentProfile !== resumeIdentity.account.profileId) ||
3375
+ (opts.model !== undefined && opts.model !== resumeIdentity.model) ||
3376
+ (opts.effort !== undefined && opts.effort !== resumeIdentity.effort))) {
3279
3377
  return { ok: false, error: 'Accepted worker execution identity cannot change' };
3280
3378
  }
3281
3379
  }
@@ -3316,7 +3414,9 @@ export class RunManager {
3316
3414
  const untouched = isUntouchedCancelledRun(run);
3317
3415
  if (!sessionStep?.sessionId && !untouched)
3318
3416
  return { ok: false, error: 'no agent session to resume' };
3319
- const originalRunner = run.runner ?? (untouched ? opts.originalRunner : undefined) ?? 'claude';
3417
+ // An accepted worker continues under the identity of the step it extends, whatever the
3418
+ // run-level (first-step) runner says.
3419
+ const originalRunner = resumeIdentity?.account.provider ?? run.runner ?? (untouched ? opts.originalRunner : undefined) ?? 'claude';
3320
3420
  const targetRunner = opts.runner ?? originalRunner;
3321
3421
  // Session ids are provider-owned opaque values. New records carry explicit
3322
3422
  // affinity; for legacy records, the run's current runner is the conservative
@@ -3328,7 +3428,9 @@ export class RunManager {
3328
3428
  // open a fresh conversation while the thread claimed it had resumed. A step that recorded no
3329
3429
  // account predates the feature and therefore ran under the discovered one.
3330
3430
  const sessionAccount = sessionStep?.profileId ?? DEFAULT_AGENT_ACCOUNT_ID;
3331
- const accountSwitched = (opts.agentProfile ?? run.agentProfile ?? sessionAccount) !== sessionAccount;
3431
+ // A mixed-chain worker's run-level account is its FIRST step's; the resumed step's own pinned
3432
+ // account is what decides whether the session can be reattached (#452).
3433
+ const accountSwitched = (opts.agentProfile ?? resumeIdentity?.account.profileId ?? run.agentProfile ?? sessionAccount) !== sessionAccount;
3332
3434
  const resume = sessionBackend === targetRunner && !accountSwitched;
3333
3435
  // Follow-up runner/model/account override (#401, spec 2026-07-29-agent-profiles): the composer
3334
3436
  // lets the user pick which backend, model and login handle this continuation — the same flat
@@ -3337,10 +3439,13 @@ export class RunManager {
3337
3439
  // run's current backend — `runContinuation` reads it off the record, later continuations
3338
3440
  // default to it, and the header reflects the active engine. An empty model ('') clears the
3339
3441
  // pin, letting the runner pick the model (auto).
3340
- if (opts.runner !== undefined ||
3442
+ // An accepted worker's identity is frozen, and the check above already required any override
3443
+ // to equal the resumed step's entry, so there is nothing to persist — and writing a step-level
3444
+ // runner onto a mixed chain's record would break the run-level identity match.
3445
+ if (resumeIdentity === undefined && (opts.runner !== undefined ||
3341
3446
  opts.model !== undefined ||
3342
3447
  opts.effort !== undefined ||
3343
- opts.agentProfile !== undefined) {
3448
+ opts.agentProfile !== undefined)) {
3344
3449
  // Guard the pairing before persisting anything: the model override applies to the runner
3345
3450
  // this continuation will actually use (`opts.runner ?? record.runner ?? 'claude'` — the
3346
3451
  // same resolution `runContinuation` reads off the record). A model that is recognizably
@@ -3825,19 +3930,6 @@ export class RunManager {
3825
3930
  // instead of `opus`). Fail loud here too rather than let the backend pick a default.
3826
3931
  let continueModel;
3827
3932
  let resumeWorkflow;
3828
- try {
3829
- const normalized = normalizeModelForBackend(continueBackend, agentModelsLocked(this.repoRoot) ? undefined : record?.model, { configuredProvider: await configuredModelProvider(continueBackend, state.cwd) });
3830
- continueModel = normalized?.backendModel;
3831
- this.store.updateRun(runId, {
3832
- modelIdentity: normalized ? formatModelIdentity(normalized.identity) : undefined,
3833
- });
3834
- }
3835
- catch (err) {
3836
- if (!(err instanceof ModelIdentityError))
3837
- throw err;
3838
- failBeforeSpawn(err.message);
3839
- return;
3840
- }
3841
3933
  // Resuming reattaches to a session that lives inside ONE account's config dir, so the
3842
3934
  // continuation must run under the account that created it — not whatever the project has
3843
3935
  // been switched to since. The owning step is the one carrying this session id.
@@ -3845,6 +3937,9 @@ export class RunManager {
3845
3937
  ? undefined
3846
3938
  : record?.steps.find((s) => s.sessionId === sessionId);
3847
3939
  const resumedProfileId = owningStep?.profileId;
3940
+ // An owned worker's turn runs under the identity pinned for the step it extends (#452).
3941
+ const toolsStep = this.continuationDefStep(record, sessionId);
3942
+ const stepIdentity = this.workerStep(runId, toolsStep?.id);
3848
3943
  // The owning step also names the session's tools: resolve `allowedTools`/`bashAllowlist`
3849
3944
  // from the persisted `workflowDef` exactly as the first spawn did (`runAgentStep`).
3850
3945
  // Rebuilding with the bare DEFAULT_ALLOWED_TOOLS silently revoked every per-step grant
@@ -3855,14 +3950,26 @@ export class RunManager {
3855
3950
  // owning session) both extend the run's tail, so they resolve from the definition's last
3856
3951
  // agent step. A legacy record without `workflowDef` (#367), or a session no step owns,
3857
3952
  // keeps today's defaults.
3858
- const defSteps = record?.workflowDef?.steps;
3859
- const toolsStep = (defSteps === undefined || (sessionId !== undefined && owningStep === undefined)
3860
- ? undefined
3861
- : defSteps.find((s) => s.id === owningStep?.id)
3862
- ?? [...defSteps].reverse().find((s) => stepKind(s) === 'agent'));
3863
- const grants = this.workerIdentity(runId)?.grants ?? {
3953
+ const grants = stepIdentity?.grants ?? {
3864
3954
  allowedTools: allowedToolsForStep(toolsStep, continueBackend), bashAllowlist: toolsStep?.bashAllowlist,
3865
3955
  };
3956
+ // The owning step's authored `model` wins over the run-level pin, exactly as the first
3957
+ // spawn resolved `step.model ?? input.model` (#464 review): a catalog worker keeps the
3958
+ // parent's inherited model at run level, so reading only the record silently switched a
3959
+ // step authored for another model on Continue.
3960
+ try {
3961
+ const normalized = normalizeModelForBackend(continueBackend, agentModelsLocked(this.repoRoot) ? undefined : stepIdentity ? stepIdentity.model : toolsStep?.model ?? record?.model, { configuredProvider: await configuredModelProvider(continueBackend, state.cwd) });
3962
+ continueModel = normalized?.backendModel;
3963
+ this.store.updateRun(runId, {
3964
+ modelIdentity: normalized ? formatModelIdentity(normalized.identity) : undefined,
3965
+ });
3966
+ }
3967
+ catch (err) {
3968
+ if (!(err instanceof ModelIdentityError))
3969
+ throw err;
3970
+ failBeforeSpawn(err.message);
3971
+ return;
3972
+ }
3866
3973
  let continueEffort;
3867
3974
  try {
3868
3975
  const modelsLocked = agentModelsLocked(this.repoRoot);
@@ -3872,7 +3979,7 @@ export class RunManager {
3872
3979
  const stepEffort = parseDelegationEffort(toolsStep?.effort);
3873
3980
  continueEffort = modelsLocked
3874
3981
  ? undefined
3875
- : authoredStepEffort ? stepEffort : record?.effort;
3982
+ : stepIdentity ? stepIdentity.effort : authoredStepEffort ? stepEffort : record?.effort;
3876
3983
  }
3877
3984
  catch (err) {
3878
3985
  failBeforeSpawn(err instanceof Error ? err.message : String(err));
@@ -3886,6 +3993,7 @@ export class RunManager {
3886
3993
  continueProfile = await this.agentEnvForStep(runId, continueBackend, {
3887
3994
  generateFollowups,
3888
3995
  recordedProfileId: resumedProfileId,
3996
+ stepId: toolsStep?.id,
3889
3997
  });
3890
3998
  }
3891
3999
  catch (err) {
@@ -4644,6 +4752,10 @@ export class RunManager {
4644
4752
  }
4645
4753
  };
4646
4754
  const stepBackend = step.runner ?? taskBackend;
4755
+ // An owned worker's step runs under what its identity pinned for it (#452): model, effort,
4756
+ // account and grants alike. The bound `workflowDef` carries the same values; the private
4757
+ // evidence is the authority.
4758
+ const stepIdentity = this.workerStep(runId, step.id);
4647
4759
  // Normalise the selected model to canonical `provider/model` and back to the
4648
4760
  // backend's own wire form via the ONE shared mapper (#405). Fail-loud: an
4649
4761
  // unresolvable model (e.g. a bare id on opencode) returns the step error
@@ -4658,8 +4770,8 @@ export class RunManager {
4658
4770
  const stepEffort = parseDelegationEffort(step.effort);
4659
4771
  effectiveEffort = modelsLocked
4660
4772
  ? undefined
4661
- : authoredStepEffort ? stepEffort : this.store.getRun(runId)?.effort ?? input.effort;
4662
- const normalized = normalizeModelForBackend(stepBackend, modelsLocked ? undefined : step.model ?? input.model, { configuredProvider: await configuredModelProvider(stepBackend, state.cwd) });
4773
+ : stepIdentity ? stepIdentity.effort : authoredStepEffort ? stepEffort : this.store.getRun(runId)?.effort ?? input.effort;
4774
+ const normalized = normalizeModelForBackend(stepBackend, modelsLocked ? undefined : stepIdentity ? stepIdentity.model : step.model ?? input.model, { configuredProvider: await configuredModelProvider(stepBackend, state.cwd) });
4663
4775
  backendModel = normalized?.backendModel;
4664
4776
  // Persist the identity of what ACTUALLY runs (#405, review M1). The run-start echo
4665
4777
  // (line ~993) is best-effort from `taskBackend`/`input.model`; a per-step `runner`/`model`
@@ -4683,6 +4795,7 @@ export class RunManager {
4683
4795
  try {
4684
4796
  stepProfile = await this.agentEnvForStep(runId, stepBackend, {
4685
4797
  generateFollowups: followupsEnabled() && input.generateFollowups !== false,
4798
+ stepId: step.id,
4686
4799
  });
4687
4800
  }
4688
4801
  catch (err) {
@@ -4694,7 +4807,7 @@ export class RunManager {
4694
4807
  if (state.cancelled || (beforeSpawn && this.executionBlockedByRootFinish(beforeSpawn)))
4695
4808
  return null;
4696
4809
  this.store.updateStep(runId, step.id, { profileId: stepProfile.profileId });
4697
- const grants = this.workerIdentity(runId)?.grants ?? {
4810
+ const grants = stepIdentity?.grants ?? {
4698
4811
  allowedTools: allowedToolsForStep(step, stepBackend), bashAllowlist: step.bashAllowlist,
4699
4812
  };
4700
4813
  state.delegationSettings = { cwd: state.cwd, runner: stepBackend, model: backendModel,